loki-mode 7.5.16 → 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.
Files changed (47) hide show
  1. package/README.md +10 -9
  2. package/SKILL.md +14 -14
  3. package/VERSION +1 -1
  4. package/autonomy/completion-council.sh +38 -9
  5. package/autonomy/lib/claude-flags.sh +132 -0
  6. package/autonomy/lib/mcp-config.sh +160 -0
  7. package/autonomy/lib/project-graph.sh +675 -0
  8. package/autonomy/lib/voter-agents.sh +356 -0
  9. package/autonomy/loki +72 -100
  10. package/autonomy/run.sh +95 -186
  11. package/bin/loki +10 -0
  12. package/dashboard/__init__.py +1 -1
  13. package/dashboard/requirements.txt +13 -8
  14. package/dashboard/server.py +53 -22
  15. package/dashboard/static/index.html +298 -299
  16. package/docs/INSTALLATION.md +54 -21
  17. package/docs/retrospectives/v7.5.15-fleet-postmortem.md +325 -0
  18. package/docs/retrospectives/v7.5.15-honesty-audit.md +136 -0
  19. package/docs/retrospectives/v7.5.15-llm-failure-modes.md +49 -0
  20. package/loki-ts/data/finding-schema.json +74 -0
  21. package/loki-ts/data/model-pricing.json +12 -0
  22. package/loki-ts/dist/loki.js +109 -108
  23. package/mcp/__init__.py +1 -1
  24. package/mcp/lsp_proxy.py +713 -0
  25. package/mcp/requirements.txt +9 -3
  26. package/mcp/tests/__init__.py +0 -0
  27. package/mcp/tests/test_lsp_proxy.py +377 -0
  28. package/memory/app_graph.py +153 -0
  29. package/memory/storage.py +6 -1
  30. package/memory/tests/test_app_graph.py +134 -0
  31. package/package.json +4 -3
  32. package/providers/claude.sh +115 -4
  33. package/providers/codex.sh +2 -2
  34. package/providers/loader.sh +4 -4
  35. package/providers/model_catalog.json +0 -9
  36. package/providers/models.sh +1 -2
  37. package/references/multi-provider.md +26 -35
  38. package/references/prompt-repetition.md +1 -1
  39. package/references/quality-control.md +1 -1
  40. package/skills/00-index.md +3 -3
  41. package/skills/model-selection.md +11 -14
  42. package/skills/providers.md +17 -57
  43. package/skills/quality-gates.md +2 -2
  44. package/skills/troubleshooting.md +1 -1
  45. package/src/integrations/github/action-handler.js +3 -2
  46. package/src/protocols/tools/start-project.js +1 -1
  47. package/providers/gemini.sh +0 -343
@@ -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()