loki-mode 7.5.17 → 7.5.27
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.
- package/README.md +10 -9
- package/SKILL.md +14 -14
- package/VERSION +1 -1
- package/autonomy/completion-council.sh +26 -3
- package/autonomy/lib/claude-flags.sh +132 -0
- package/autonomy/lib/mcp-config.sh +160 -0
- package/autonomy/lib/project-graph.sh +675 -0
- package/autonomy/lib/voter-agents.sh +356 -0
- package/autonomy/loki +61 -96
- package/autonomy/run.sh +95 -186
- package/bin/loki +10 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/requirements.txt +13 -8
- package/dashboard/server.py +33 -15
- package/dashboard/static/index.html +298 -299
- package/docs/INSTALLATION.md +54 -21
- package/docs/retrospectives/v7.5.15-fleet-postmortem.md +325 -0
- package/docs/retrospectives/v7.5.15-honesty-audit.md +136 -0
- package/docs/retrospectives/v7.5.15-llm-failure-modes.md +49 -0
- package/loki-ts/data/finding-schema.json +74 -0
- package/loki-ts/data/model-pricing.json +12 -0
- package/loki-ts/dist/loki.js +109 -108
- package/mcp/__init__.py +1 -1
- package/mcp/lsp_proxy.py +713 -0
- package/mcp/requirements.txt +9 -3
- package/mcp/tests/__init__.py +0 -0
- package/mcp/tests/test_lsp_proxy.py +377 -0
- package/memory/app_graph.py +153 -0
- package/memory/storage.py +6 -1
- package/memory/tests/test_app_graph.py +134 -0
- package/package.json +4 -3
- package/providers/claude.sh +115 -4
- package/providers/codex.sh +2 -2
- package/providers/loader.sh +4 -4
- package/providers/model_catalog.json +0 -9
- package/providers/models.sh +1 -2
- package/references/multi-provider.md +26 -35
- package/references/prompt-repetition.md +1 -1
- package/references/quality-control.md +1 -1
- package/skills/00-index.md +3 -3
- package/skills/model-selection.md +11 -14
- package/skills/providers.md +17 -57
- package/skills/quality-gates.md +2 -2
- package/skills/troubleshooting.md +1 -1
- package/src/integrations/github/action-handler.js +3 -2
- package/src/protocols/tools/start-project.js +1 -1
- package/providers/gemini.sh +0 -343
package/mcp/lsp_proxy.py
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Loki Mode LSP Proxy MCP Server (v7.5.24 Phase G)
|
|
4
|
+
|
|
5
|
+
Exposes Language Server Protocol (LSP) capabilities to MCP clients by
|
|
6
|
+
proxying requests to per-language LSP binaries that the user already has
|
|
7
|
+
installed on PATH. The server is silent about missing binaries: if a
|
|
8
|
+
language's LSP is not detected, tools targeting that language return a
|
|
9
|
+
structured `{"error": ...}` payload but the server itself stays up.
|
|
10
|
+
|
|
11
|
+
Architecture:
|
|
12
|
+
- Stdlib only (json, subprocess, shutil, threading, os, pathlib,
|
|
13
|
+
atexit, signal, time, urllib). NO new pip dependencies.
|
|
14
|
+
- Lazy lifecycle: an `LSPClient` is spawned on the first tool call
|
|
15
|
+
that needs a given language. Spawn does the LSP `initialize`
|
|
16
|
+
handshake and caches `didOpen` per file URI.
|
|
17
|
+
- JSON-RPC 2.0 over stdio framed with `Content-Length` headers
|
|
18
|
+
(LSP spec, NOT line-delimited JSON).
|
|
19
|
+
- Cleanup: atexit handler sends `shutdown` + `exit` and SIGTERMs
|
|
20
|
+
the subprocess after a 2s grace, then removes the PID file at
|
|
21
|
+
`.loki/lsp/pids.json`.
|
|
22
|
+
|
|
23
|
+
Supported languages (suffix -> binary):
|
|
24
|
+
.ts/.tsx/.js/.jsx -> typescript-language-server
|
|
25
|
+
.py -> pylsp
|
|
26
|
+
.go -> gopls
|
|
27
|
+
.rs -> rust-analyzer
|
|
28
|
+
|
|
29
|
+
Tools:
|
|
30
|
+
lsp_find_references(file, line, character, include_declaration=False)
|
|
31
|
+
lsp_go_to_definition(file, line, character)
|
|
32
|
+
lsp_symbol_at_position(file, line, character)
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
python3 -m mcp.lsp_proxy # stdio mode (default)
|
|
36
|
+
python3 -m mcp.lsp_proxy --transport http --port 8422
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import atexit
|
|
42
|
+
import importlib.util
|
|
43
|
+
import json
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import shutil
|
|
47
|
+
import signal
|
|
48
|
+
import site
|
|
49
|
+
import subprocess
|
|
50
|
+
import sys
|
|
51
|
+
import threading
|
|
52
|
+
import time
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
55
|
+
from urllib.parse import quote
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ============================================================
|
|
59
|
+
# LOGGING (stderr; stdio transport reserves stdout for JSON-RPC)
|
|
60
|
+
# ============================================================
|
|
61
|
+
|
|
62
|
+
logging.basicConfig(
|
|
63
|
+
level=logging.INFO,
|
|
64
|
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
65
|
+
stream=sys.stderr,
|
|
66
|
+
)
|
|
67
|
+
logger = logging.getLogger('loki-mcp-lsp-proxy')
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ============================================================
|
|
71
|
+
# LANGUAGE / BINARY MAPPING
|
|
72
|
+
# ============================================================
|
|
73
|
+
|
|
74
|
+
# Each entry: (language_id_for_LSP, list_of_file_suffixes, binary_name,
|
|
75
|
+
# extra_args_for_invocation). language_id is the LSP `languageId`
|
|
76
|
+
# string used in `textDocument/didOpen` params (LSP spec section
|
|
77
|
+
# "Text Documents").
|
|
78
|
+
LANG_MAP: Dict[str, Tuple[str, List[str], str, List[str]]] = {
|
|
79
|
+
'typescript': (
|
|
80
|
+
'typescript', ['.ts', '.tsx'],
|
|
81
|
+
'typescript-language-server', ['--stdio'],
|
|
82
|
+
),
|
|
83
|
+
'javascript': (
|
|
84
|
+
'javascript', ['.js', '.jsx', '.mjs', '.cjs'],
|
|
85
|
+
'typescript-language-server', ['--stdio'],
|
|
86
|
+
),
|
|
87
|
+
'python': (
|
|
88
|
+
'python', ['.py'],
|
|
89
|
+
'pylsp', [],
|
|
90
|
+
),
|
|
91
|
+
'go': (
|
|
92
|
+
'go', ['.go'],
|
|
93
|
+
'gopls', [],
|
|
94
|
+
),
|
|
95
|
+
'rust': (
|
|
96
|
+
'rust', ['.rs'],
|
|
97
|
+
'rust-analyzer', [],
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _suffix_to_language(file_path: str) -> Optional[str]:
|
|
103
|
+
"""Return the LANG_MAP key whose suffix list contains the file's
|
|
104
|
+
extension, or None if no match. Suffix comparison is lowercase."""
|
|
105
|
+
suffix = Path(file_path).suffix.lower()
|
|
106
|
+
if not suffix:
|
|
107
|
+
return None
|
|
108
|
+
for lang, (_lsp_id, suffixes, _bin, _args) in LANG_MAP.items():
|
|
109
|
+
if suffix in suffixes:
|
|
110
|
+
return lang
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ============================================================
|
|
115
|
+
# DETECTION
|
|
116
|
+
# ============================================================
|
|
117
|
+
|
|
118
|
+
_detected: Optional[Dict[str, str]] = None
|
|
119
|
+
_detected_lock = threading.Lock()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _detect_lsps() -> Dict[str, str]:
|
|
123
|
+
"""Return {language: absolute_binary_path} for each LANG_MAP entry
|
|
124
|
+
whose binary is found on PATH. Result is cached per process; call
|
|
125
|
+
`_reset_detection_cache()` in tests.
|
|
126
|
+
|
|
127
|
+
Multiple language entries may share a binary (typescript +
|
|
128
|
+
javascript both use typescript-language-server). That is handled
|
|
129
|
+
by resolving each entry independently."""
|
|
130
|
+
global _detected
|
|
131
|
+
with _detected_lock:
|
|
132
|
+
if _detected is not None:
|
|
133
|
+
return dict(_detected)
|
|
134
|
+
result: Dict[str, str] = {}
|
|
135
|
+
for lang, (_lsp_id, _suffixes, bin_name, _args) in LANG_MAP.items():
|
|
136
|
+
resolved = shutil.which(bin_name)
|
|
137
|
+
if resolved:
|
|
138
|
+
result[lang] = resolved
|
|
139
|
+
_detected = result
|
|
140
|
+
return dict(result)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _reset_detection_cache() -> None:
|
|
144
|
+
"""Test hook: clear the cached detection result so the next
|
|
145
|
+
`_detect_lsps()` call re-runs `shutil.which`."""
|
|
146
|
+
global _detected
|
|
147
|
+
with _detected_lock:
|
|
148
|
+
_detected = None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ============================================================
|
|
152
|
+
# LSP WIRE: Content-Length framed JSON-RPC 2.0
|
|
153
|
+
# ============================================================
|
|
154
|
+
|
|
155
|
+
def _write_lsp(stdin, msg: Dict[str, Any]) -> None:
|
|
156
|
+
"""Encode `msg` as JSON-RPC 2.0 with `Content-Length` framing per
|
|
157
|
+
LSP spec and write it to `stdin`. `stdin` must accept bytes."""
|
|
158
|
+
body = json.dumps(msg, separators=(',', ':')).encode('utf-8')
|
|
159
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode('ascii')
|
|
160
|
+
stdin.write(header)
|
|
161
|
+
stdin.write(body)
|
|
162
|
+
stdin.flush()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _read_lsp(stdout) -> Optional[Dict[str, Any]]:
|
|
166
|
+
"""Read one Content-Length-framed JSON-RPC message from `stdout`.
|
|
167
|
+
|
|
168
|
+
Returns the decoded dict, or None on EOF / malformed framing.
|
|
169
|
+
Headers are parsed line-by-line until a blank line; Content-Length
|
|
170
|
+
is required, other headers (e.g. Content-Type) are tolerated and
|
|
171
|
+
ignored."""
|
|
172
|
+
content_length: Optional[int] = None
|
|
173
|
+
while True:
|
|
174
|
+
line = stdout.readline()
|
|
175
|
+
if not line:
|
|
176
|
+
return None
|
|
177
|
+
# Lines end in \r\n; rstrip handles both \r\n and \n for robustness
|
|
178
|
+
line = line.rstrip(b'\r\n')
|
|
179
|
+
if line == b'':
|
|
180
|
+
break
|
|
181
|
+
if b':' not in line:
|
|
182
|
+
# Malformed header; skip rather than crash
|
|
183
|
+
continue
|
|
184
|
+
name, _, value = line.partition(b':')
|
|
185
|
+
if name.strip().lower() == b'content-length':
|
|
186
|
+
try:
|
|
187
|
+
content_length = int(value.strip())
|
|
188
|
+
except ValueError:
|
|
189
|
+
return None
|
|
190
|
+
if content_length is None or content_length < 0:
|
|
191
|
+
return None
|
|
192
|
+
body = b''
|
|
193
|
+
remaining = content_length
|
|
194
|
+
while remaining > 0:
|
|
195
|
+
chunk = stdout.read(remaining)
|
|
196
|
+
if not chunk:
|
|
197
|
+
return None
|
|
198
|
+
body += chunk
|
|
199
|
+
remaining -= len(chunk)
|
|
200
|
+
try:
|
|
201
|
+
return json.loads(body.decode('utf-8'))
|
|
202
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ============================================================
|
|
207
|
+
# LSP CLIENT (one subprocess per language)
|
|
208
|
+
# ============================================================
|
|
209
|
+
|
|
210
|
+
def _path_to_uri(path: str) -> str:
|
|
211
|
+
"""Convert an absolute filesystem path to a file:// URI per RFC 8089.
|
|
212
|
+
Windows paths are out of scope (LSP servers we target are POSIX in
|
|
213
|
+
Loki's supported envs); we still encode each path segment to handle
|
|
214
|
+
spaces / unicode safely."""
|
|
215
|
+
abs_path = os.path.abspath(path)
|
|
216
|
+
# quote with safe="/" so directory separators stay intact.
|
|
217
|
+
return 'file://' + quote(abs_path, safe='/')
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class LSPClient:
|
|
221
|
+
"""Wraps a single LSP subprocess. One client per language per
|
|
222
|
+
process. Not safe for concurrent use across threads on the same
|
|
223
|
+
client without external locking; we serialize via `_lock`."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, language: str, binary_path: str, extra_args: List[str]):
|
|
226
|
+
self.language = language
|
|
227
|
+
self.binary_path = binary_path
|
|
228
|
+
self.extra_args = list(extra_args)
|
|
229
|
+
self.proc: Optional[subprocess.Popen] = None
|
|
230
|
+
self._next_id = 1
|
|
231
|
+
self._opened_uris: set = set()
|
|
232
|
+
self._lock = threading.Lock()
|
|
233
|
+
self._initialized = False
|
|
234
|
+
|
|
235
|
+
def start(self) -> None:
|
|
236
|
+
"""Spawn the subprocess and perform the LSP `initialize` +
|
|
237
|
+
`initialized` handshake. Idempotent: re-calling start() on an
|
|
238
|
+
already-initialized client is a no-op."""
|
|
239
|
+
if self._initialized and self.proc and self.proc.poll() is None:
|
|
240
|
+
return
|
|
241
|
+
cmd = [self.binary_path] + self.extra_args
|
|
242
|
+
self.proc = subprocess.Popen(
|
|
243
|
+
cmd,
|
|
244
|
+
stdin=subprocess.PIPE,
|
|
245
|
+
stdout=subprocess.PIPE,
|
|
246
|
+
stderr=subprocess.DEVNULL,
|
|
247
|
+
bufsize=0,
|
|
248
|
+
)
|
|
249
|
+
# LSP `initialize` request. processId / rootUri / capabilities
|
|
250
|
+
# are required by spec; capabilities={} signals "no extras",
|
|
251
|
+
# which servers accept.
|
|
252
|
+
root_uri = _path_to_uri(os.getcwd())
|
|
253
|
+
init_id = self._next_request_id()
|
|
254
|
+
_write_lsp(self.proc.stdin, {
|
|
255
|
+
'jsonrpc': '2.0',
|
|
256
|
+
'id': init_id,
|
|
257
|
+
'method': 'initialize',
|
|
258
|
+
'params': {
|
|
259
|
+
'processId': os.getpid(),
|
|
260
|
+
'rootUri': root_uri,
|
|
261
|
+
'capabilities': {},
|
|
262
|
+
'clientInfo': {
|
|
263
|
+
'name': 'loki-mode-lsp-proxy',
|
|
264
|
+
'version': '7.5.24',
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
# Read messages until we see the response to init_id; tolerate
|
|
269
|
+
# interleaved notifications.
|
|
270
|
+
deadline = time.time() + 10.0
|
|
271
|
+
while time.time() < deadline:
|
|
272
|
+
msg = _read_lsp(self.proc.stdout)
|
|
273
|
+
if msg is None:
|
|
274
|
+
break
|
|
275
|
+
if msg.get('id') == init_id:
|
|
276
|
+
break
|
|
277
|
+
# `initialized` is a notification (no id, no response expected).
|
|
278
|
+
_write_lsp(self.proc.stdin, {
|
|
279
|
+
'jsonrpc': '2.0',
|
|
280
|
+
'method': 'initialized',
|
|
281
|
+
'params': {},
|
|
282
|
+
})
|
|
283
|
+
self._initialized = True
|
|
284
|
+
_record_pid_to_disk(self.language, self.proc.pid)
|
|
285
|
+
|
|
286
|
+
def _next_request_id(self) -> int:
|
|
287
|
+
rid = self._next_id
|
|
288
|
+
self._next_id += 1
|
|
289
|
+
return rid
|
|
290
|
+
|
|
291
|
+
def did_open(self, file_path: str) -> None:
|
|
292
|
+
"""Send `textDocument/didOpen` for `file_path` if not already
|
|
293
|
+
opened. Reads file contents from disk; silently no-ops if the
|
|
294
|
+
file is unreadable (subsequent request will fail with a clean
|
|
295
|
+
LSP error rather than crashing the proxy)."""
|
|
296
|
+
uri = _path_to_uri(file_path)
|
|
297
|
+
if uri in self._opened_uris:
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
with open(file_path, 'r', encoding='utf-8', errors='replace') as fh:
|
|
301
|
+
text = fh.read()
|
|
302
|
+
except OSError:
|
|
303
|
+
return
|
|
304
|
+
lsp_id, _suffixes, _bin, _args = LANG_MAP[self.language]
|
|
305
|
+
_write_lsp(self.proc.stdin, {
|
|
306
|
+
'jsonrpc': '2.0',
|
|
307
|
+
'method': 'textDocument/didOpen',
|
|
308
|
+
'params': {
|
|
309
|
+
'textDocument': {
|
|
310
|
+
'uri': uri,
|
|
311
|
+
'languageId': lsp_id,
|
|
312
|
+
'version': 1,
|
|
313
|
+
'text': text,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
self._opened_uris.add(uri)
|
|
318
|
+
|
|
319
|
+
def request(self, method: str, params: Dict[str, Any], timeout: float = 5.0) -> Dict[str, Any]:
|
|
320
|
+
"""Send a JSON-RPC request and block until its response (or
|
|
321
|
+
timeout / EOF) arrives. Returns the decoded LSP response dict
|
|
322
|
+
(which has 'result' on success and 'error' on failure)."""
|
|
323
|
+
rid = self._next_request_id()
|
|
324
|
+
_write_lsp(self.proc.stdin, {
|
|
325
|
+
'jsonrpc': '2.0',
|
|
326
|
+
'id': rid,
|
|
327
|
+
'method': method,
|
|
328
|
+
'params': params,
|
|
329
|
+
})
|
|
330
|
+
deadline = time.time() + timeout
|
|
331
|
+
while time.time() < deadline:
|
|
332
|
+
msg = _read_lsp(self.proc.stdout)
|
|
333
|
+
if msg is None:
|
|
334
|
+
return {'error': {'message': 'LSP EOF before response'}}
|
|
335
|
+
if msg.get('id') == rid:
|
|
336
|
+
return msg
|
|
337
|
+
# Ignore notifications and unrelated request responses.
|
|
338
|
+
return {'error': {'message': f'LSP timeout after {timeout}s'}}
|
|
339
|
+
|
|
340
|
+
def shutdown(self) -> None:
|
|
341
|
+
"""Send `shutdown` + `exit`, then SIGTERM after a 2s grace and
|
|
342
|
+
SIGKILL after another 1s if still alive."""
|
|
343
|
+
if not self.proc or self.proc.poll() is not None:
|
|
344
|
+
return
|
|
345
|
+
try:
|
|
346
|
+
with self._lock:
|
|
347
|
+
_write_lsp(self.proc.stdin, {
|
|
348
|
+
'jsonrpc': '2.0',
|
|
349
|
+
'id': self._next_request_id(),
|
|
350
|
+
'method': 'shutdown',
|
|
351
|
+
'params': None,
|
|
352
|
+
})
|
|
353
|
+
_write_lsp(self.proc.stdin, {
|
|
354
|
+
'jsonrpc': '2.0',
|
|
355
|
+
'method': 'exit',
|
|
356
|
+
'params': None,
|
|
357
|
+
})
|
|
358
|
+
except (BrokenPipeError, OSError):
|
|
359
|
+
pass
|
|
360
|
+
# Give the LSP 2s to exit on its own after `exit` notification.
|
|
361
|
+
deadline = time.time() + 2.0
|
|
362
|
+
while time.time() < deadline and self.proc.poll() is None:
|
|
363
|
+
time.sleep(0.05)
|
|
364
|
+
if self.proc.poll() is None:
|
|
365
|
+
try:
|
|
366
|
+
self.proc.terminate()
|
|
367
|
+
except OSError:
|
|
368
|
+
pass
|
|
369
|
+
deadline = time.time() + 1.0
|
|
370
|
+
while time.time() < deadline and self.proc.poll() is None:
|
|
371
|
+
time.sleep(0.05)
|
|
372
|
+
if self.proc.poll() is None:
|
|
373
|
+
try:
|
|
374
|
+
self.proc.kill()
|
|
375
|
+
except OSError:
|
|
376
|
+
pass
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ============================================================
|
|
380
|
+
# CLIENT REGISTRY (lazy spawn)
|
|
381
|
+
# ============================================================
|
|
382
|
+
|
|
383
|
+
_clients: Dict[str, LSPClient] = {}
|
|
384
|
+
_clients_lock = threading.Lock()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _get_or_spawn_client(language: str) -> Optional[LSPClient]:
|
|
388
|
+
"""Return an initialized LSPClient for `language`, spawning it on
|
|
389
|
+
first request. Returns None if the language's binary is absent
|
|
390
|
+
(silent skip per spec)."""
|
|
391
|
+
detected = _detect_lsps()
|
|
392
|
+
if language not in detected:
|
|
393
|
+
return None
|
|
394
|
+
with _clients_lock:
|
|
395
|
+
client = _clients.get(language)
|
|
396
|
+
if client is not None and client.proc and client.proc.poll() is None:
|
|
397
|
+
return client
|
|
398
|
+
_lsp_id, _suffixes, _bin, extra_args = LANG_MAP[language]
|
|
399
|
+
client = LSPClient(language, detected[language], extra_args)
|
|
400
|
+
# start() acquires no shared lock; do it outside _clients_lock to
|
|
401
|
+
# avoid holding the lock across a multi-second handshake.
|
|
402
|
+
try:
|
|
403
|
+
client.start()
|
|
404
|
+
except (OSError, FileNotFoundError) as exc:
|
|
405
|
+
logger.warning("LSP spawn failed for %s: %s", language, exc)
|
|
406
|
+
return None
|
|
407
|
+
with _clients_lock:
|
|
408
|
+
_clients[language] = client
|
|
409
|
+
return client
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ============================================================
|
|
413
|
+
# PID PERSISTENCE (so `loki doctor` can reap stragglers)
|
|
414
|
+
# ============================================================
|
|
415
|
+
|
|
416
|
+
def _pids_file() -> Path:
|
|
417
|
+
return Path.cwd() / '.loki' / 'lsp' / 'pids.json'
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _record_pid_to_disk(language: str, pid: int) -> None:
|
|
421
|
+
"""Append the language -> pid mapping to .loki/lsp/pids.json.
|
|
422
|
+
Best-effort; failures to write are logged but never raised."""
|
|
423
|
+
try:
|
|
424
|
+
path = _pids_file()
|
|
425
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
426
|
+
data: Dict[str, int] = {}
|
|
427
|
+
if path.is_file():
|
|
428
|
+
try:
|
|
429
|
+
with open(path, 'r', encoding='utf-8') as fh:
|
|
430
|
+
data = json.load(fh) or {}
|
|
431
|
+
except (json.JSONDecodeError, OSError):
|
|
432
|
+
data = {}
|
|
433
|
+
data[language] = pid
|
|
434
|
+
with open(path, 'w', encoding='utf-8') as fh:
|
|
435
|
+
json.dump(data, fh, indent=2, sort_keys=True)
|
|
436
|
+
except OSError as exc:
|
|
437
|
+
logger.debug("Could not record PID for %s: %s", language, exc)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _clear_pid_file() -> None:
|
|
441
|
+
"""Remove the PID file at process exit."""
|
|
442
|
+
try:
|
|
443
|
+
path = _pids_file()
|
|
444
|
+
if path.is_file():
|
|
445
|
+
path.unlink()
|
|
446
|
+
except OSError:
|
|
447
|
+
pass
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ============================================================
|
|
451
|
+
# CLEANUP (atexit)
|
|
452
|
+
# ============================================================
|
|
453
|
+
|
|
454
|
+
def _cleanup_all_clients() -> None:
|
|
455
|
+
"""atexit handler: shutdown every spawned LSPClient and clear the
|
|
456
|
+
PID file. Safe to call multiple times."""
|
|
457
|
+
with _clients_lock:
|
|
458
|
+
clients = list(_clients.values())
|
|
459
|
+
_clients.clear()
|
|
460
|
+
for c in clients:
|
|
461
|
+
try:
|
|
462
|
+
c.shutdown()
|
|
463
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
464
|
+
logger.debug("Cleanup for %s raised: %s", c.language, exc)
|
|
465
|
+
_clear_pid_file()
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
atexit.register(_cleanup_all_clients)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ============================================================
|
|
472
|
+
# TOOL DISPATCH HELPER
|
|
473
|
+
# ============================================================
|
|
474
|
+
|
|
475
|
+
def _dispatch_lsp(file: str, line: int, character: int,
|
|
476
|
+
method: str, extra_params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
477
|
+
"""Route a tool call to the right LSP client and return a normalized
|
|
478
|
+
response dict. Common error shapes (no language detected, no binary
|
|
479
|
+
on PATH, LSP error) are returned as `{"error": "..."}`."""
|
|
480
|
+
language = _suffix_to_language(file)
|
|
481
|
+
if language is None:
|
|
482
|
+
return {'error': f'Unsupported file type: {file}'}
|
|
483
|
+
client = _get_or_spawn_client(language)
|
|
484
|
+
if client is None:
|
|
485
|
+
return {'error': f'No LSP detected for language: {language}'}
|
|
486
|
+
try:
|
|
487
|
+
client.did_open(file)
|
|
488
|
+
except (BrokenPipeError, OSError) as exc:
|
|
489
|
+
return {'error': f'LSP I/O failure on didOpen: {exc}'}
|
|
490
|
+
params: Dict[str, Any] = {
|
|
491
|
+
'textDocument': {'uri': _path_to_uri(file)},
|
|
492
|
+
'position': {'line': int(line), 'character': int(character)},
|
|
493
|
+
}
|
|
494
|
+
if extra_params:
|
|
495
|
+
params.update(extra_params)
|
|
496
|
+
try:
|
|
497
|
+
resp = client.request(method, params)
|
|
498
|
+
except (BrokenPipeError, OSError) as exc:
|
|
499
|
+
return {'error': f'LSP I/O failure on {method}: {exc}'}
|
|
500
|
+
if 'error' in resp:
|
|
501
|
+
err = resp['error']
|
|
502
|
+
msg = err.get('message') if isinstance(err, dict) else str(err)
|
|
503
|
+
return {'error': msg, 'language': language}
|
|
504
|
+
return {'result': resp.get('result'), 'language': language}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ============================================================
|
|
508
|
+
# FASTMCP LOADING
|
|
509
|
+
# ============================================================
|
|
510
|
+
# Same trick as mcp/server.py: the local `mcp/` package shadows the pip
|
|
511
|
+
# `mcp` SDK, so we have to load FastMCP from site-packages via
|
|
512
|
+
# importlib.util. Unlike server.py we do NOT sys.exit() when the SDK
|
|
513
|
+
# isn't installed -- we install a no-op shim so the module imports
|
|
514
|
+
# cleanly under test (and so production-without-MCP-SDK fails on
|
|
515
|
+
# `.run()` rather than at import time, matching the silent-skip
|
|
516
|
+
# philosophy).
|
|
517
|
+
|
|
518
|
+
class _NoopFastMCP:
|
|
519
|
+
"""Fallback used when the pip `mcp` SDK is not installed. Tool
|
|
520
|
+
decorators become identity, run() raises a clear error."""
|
|
521
|
+
|
|
522
|
+
def __init__(self, *args, **kwargs):
|
|
523
|
+
pass
|
|
524
|
+
|
|
525
|
+
def tool(self, *args, **kwargs):
|
|
526
|
+
def deco(fn):
|
|
527
|
+
return fn
|
|
528
|
+
return deco
|
|
529
|
+
|
|
530
|
+
def run(self, *args, **kwargs):
|
|
531
|
+
raise RuntimeError(
|
|
532
|
+
"MCP SDK (pip package 'mcp') not installed. "
|
|
533
|
+
"Install with: pip install mcp"
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _load_fastmcp():
|
|
538
|
+
"""Walk site-packages for the pip `mcp` SDK's FastMCP class.
|
|
539
|
+
Returns the FastMCP class on success, or `_NoopFastMCP` on failure
|
|
540
|
+
(so import never raises)."""
|
|
541
|
+
search_paths: List[str] = []
|
|
542
|
+
try:
|
|
543
|
+
search_paths.extend(site.getsitepackages())
|
|
544
|
+
except AttributeError:
|
|
545
|
+
pass
|
|
546
|
+
try:
|
|
547
|
+
search_paths.append(site.getusersitepackages())
|
|
548
|
+
except AttributeError:
|
|
549
|
+
pass
|
|
550
|
+
for site_dir in search_paths:
|
|
551
|
+
# First shape: fastmcp.py module file.
|
|
552
|
+
candidate_file = os.path.join(site_dir, 'mcp', 'server', 'fastmcp.py')
|
|
553
|
+
if os.path.isfile(candidate_file):
|
|
554
|
+
spec = importlib.util.spec_from_file_location(
|
|
555
|
+
'mcp_pip_sdk_lsp.server.fastmcp', candidate_file,
|
|
556
|
+
submodule_search_locations=[],
|
|
557
|
+
)
|
|
558
|
+
if spec and spec.loader:
|
|
559
|
+
mod = importlib.util.module_from_spec(spec)
|
|
560
|
+
try:
|
|
561
|
+
spec.loader.exec_module(mod)
|
|
562
|
+
return mod.FastMCP
|
|
563
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
564
|
+
logger.debug("FastMCP file-import failed: %s", exc)
|
|
565
|
+
# Second shape: fastmcp/__init__.py package directory.
|
|
566
|
+
candidate_pkg = os.path.join(site_dir, 'mcp', 'server', 'fastmcp', '__init__.py')
|
|
567
|
+
if os.path.isfile(candidate_pkg):
|
|
568
|
+
spec = importlib.util.spec_from_file_location(
|
|
569
|
+
'mcp_pip_sdk_lsp.server.fastmcp', candidate_pkg,
|
|
570
|
+
submodule_search_locations=[
|
|
571
|
+
os.path.join(site_dir, 'mcp', 'server', 'fastmcp'),
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
if spec and spec.loader:
|
|
575
|
+
mod = importlib.util.module_from_spec(spec)
|
|
576
|
+
try:
|
|
577
|
+
spec.loader.exec_module(mod)
|
|
578
|
+
if hasattr(mod, 'FastMCP'):
|
|
579
|
+
return mod.FastMCP
|
|
580
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
581
|
+
logger.debug("FastMCP pkg-import failed: %s", exc)
|
|
582
|
+
logger.warning("MCP SDK not found; LSP proxy MCP server will not be runnable.")
|
|
583
|
+
return _NoopFastMCP
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
FastMCP = _load_fastmcp()
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# Read version from VERSION file
|
|
590
|
+
try:
|
|
591
|
+
_version_path = os.path.join(os.path.dirname(__file__), '..', 'VERSION')
|
|
592
|
+
with open(_version_path, 'r', encoding='utf-8') as _vf:
|
|
593
|
+
_version = _vf.read().strip()
|
|
594
|
+
except Exception:
|
|
595
|
+
_version = 'unknown'
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
mcp = FastMCP(
|
|
599
|
+
'loki-mode-lsp-proxy',
|
|
600
|
+
version=_version,
|
|
601
|
+
description='Loki Mode LSP proxy: find references, go to definition, '
|
|
602
|
+
'symbol-at-position via on-PATH language servers.',
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
# ============================================================
|
|
607
|
+
# MCP TOOL FUNCTIONS
|
|
608
|
+
# ============================================================
|
|
609
|
+
|
|
610
|
+
@mcp.tool()
|
|
611
|
+
async def lsp_find_references(file: str, line: int, character: int,
|
|
612
|
+
include_declaration: bool = False) -> str:
|
|
613
|
+
"""Find references to the symbol at the given file / line / character.
|
|
614
|
+
|
|
615
|
+
Args:
|
|
616
|
+
file: Absolute or cwd-relative path to the source file.
|
|
617
|
+
line: 0-indexed line number (LSP convention).
|
|
618
|
+
character: 0-indexed character offset within the line.
|
|
619
|
+
include_declaration: If True, include the symbol declaration in
|
|
620
|
+
results.
|
|
621
|
+
|
|
622
|
+
Returns:
|
|
623
|
+
JSON-encoded string. Success: {"result": [...], "language": ...}.
|
|
624
|
+
Error: {"error": "..."}.
|
|
625
|
+
"""
|
|
626
|
+
result = _dispatch_lsp(
|
|
627
|
+
file, line, character, 'textDocument/references',
|
|
628
|
+
extra_params={'context': {'includeDeclaration': bool(include_declaration)}},
|
|
629
|
+
)
|
|
630
|
+
return json.dumps(result)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
@mcp.tool()
|
|
634
|
+
async def lsp_go_to_definition(file: str, line: int, character: int) -> str:
|
|
635
|
+
"""Resolve the definition location for the symbol at file / line /
|
|
636
|
+
character.
|
|
637
|
+
|
|
638
|
+
Args:
|
|
639
|
+
file: Absolute or cwd-relative path to the source file.
|
|
640
|
+
line: 0-indexed line number.
|
|
641
|
+
character: 0-indexed character offset within the line.
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
JSON-encoded string with `result` (LSP Location | Location[] |
|
|
645
|
+
LocationLink[]) on success or `error` on failure.
|
|
646
|
+
"""
|
|
647
|
+
result = _dispatch_lsp(
|
|
648
|
+
file, line, character, 'textDocument/definition',
|
|
649
|
+
)
|
|
650
|
+
return json.dumps(result)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@mcp.tool()
|
|
654
|
+
async def lsp_symbol_at_position(file: str, line: int, character: int) -> str:
|
|
655
|
+
"""Return the hover / symbol info at the given file / line /
|
|
656
|
+
character. Uses LSP `textDocument/hover` which returns a
|
|
657
|
+
`MarkupContent` plus an optional range.
|
|
658
|
+
|
|
659
|
+
Args:
|
|
660
|
+
file: Absolute or cwd-relative path to the source file.
|
|
661
|
+
line: 0-indexed line number.
|
|
662
|
+
character: 0-indexed character offset within the line.
|
|
663
|
+
|
|
664
|
+
Returns:
|
|
665
|
+
JSON-encoded string with `result` (LSP Hover) on success or
|
|
666
|
+
`error` on failure.
|
|
667
|
+
"""
|
|
668
|
+
result = _dispatch_lsp(
|
|
669
|
+
file, line, character, 'textDocument/hover',
|
|
670
|
+
)
|
|
671
|
+
return json.dumps(result)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ============================================================
|
|
675
|
+
# MAIN
|
|
676
|
+
# ============================================================
|
|
677
|
+
|
|
678
|
+
def main() -> None:
|
|
679
|
+
import argparse
|
|
680
|
+
parser = argparse.ArgumentParser(
|
|
681
|
+
description='Loki Mode LSP Proxy MCP Server',
|
|
682
|
+
)
|
|
683
|
+
parser.add_argument(
|
|
684
|
+
'--transport', choices=['stdio', 'http'], default='stdio',
|
|
685
|
+
help='Transport mechanism (default: stdio).',
|
|
686
|
+
)
|
|
687
|
+
parser.add_argument(
|
|
688
|
+
'--port', type=int, default=8422,
|
|
689
|
+
help='Port for HTTP transport (default: 8422).',
|
|
690
|
+
)
|
|
691
|
+
args = parser.parse_args()
|
|
692
|
+
# SIGTERM handler so docker stop / supervisord stop triggers cleanup
|
|
693
|
+
# symmetrically to atexit. Re-raises via default handler so the
|
|
694
|
+
# process actually exits.
|
|
695
|
+
def _sigterm(_signum, _frame):
|
|
696
|
+
_cleanup_all_clients()
|
|
697
|
+
sys.exit(0)
|
|
698
|
+
try:
|
|
699
|
+
signal.signal(signal.SIGTERM, _sigterm)
|
|
700
|
+
except (ValueError, OSError):
|
|
701
|
+
# Some test runners install their own handlers; non-fatal.
|
|
702
|
+
pass
|
|
703
|
+
logger.info("Starting Loki Mode LSP proxy (transport: %s)", args.transport)
|
|
704
|
+
detected = _detect_lsps()
|
|
705
|
+
logger.info("Detected LSPs: %s", sorted(detected.keys()) or 'none')
|
|
706
|
+
if args.transport == 'http':
|
|
707
|
+
mcp.run(transport='http', port=args.port)
|
|
708
|
+
else:
|
|
709
|
+
mcp.run(transport='stdio')
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
if __name__ == '__main__':
|
|
713
|
+
main()
|