Skip to content

Commit 9cb0e0c

Browse files
committed
fix idempotent LSP bootstrap
1 parent 390da3b commit 9cb0e0c

3 files changed

Lines changed: 93 additions & 41 deletions

File tree

scripts/bootstrap.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,20 @@ def install_wrapper(name: str, target: Path, arguments: list[str] | None = None)
4848
bin_dir = venv_bin_dir()
4949
bin_dir.mkdir(parents=True, exist_ok=True)
5050
arguments = arguments or []
51+
wrapper = bin_dir / (f"{name}.cmd" if platform.system() == "Windows" else name)
52+
if target.resolve() == wrapper.resolve():
53+
raise RuntimeError(
54+
f"Refusing to create a recursive wrapper for {name}: {wrapper}"
55+
)
5156

5257
if platform.system() == "Windows":
53-
wrapper = bin_dir / f"{name}.cmd"
5458
rendered_args = " ".join(f'"{value}"' for value in arguments)
5559
wrapper.write_text(
5660
f'@echo off\r\n"{target}" {rendered_args} %*\r\n',
5761
encoding="utf-8",
5862
)
5963
return
6064

61-
wrapper = bin_dir / name
6265
rendered_args = " ".join(_shell_quote(value) for value in arguments)
6366
wrapper.write_text(
6467
f"#!/usr/bin/env sh\nexec {_shell_quote(str(target))} {rendered_args} \"$@\"\n",
@@ -145,23 +148,17 @@ def ensure_jdtls(allow_download: bool) -> None:
145148

146149
def ensure_lsp_mcp(allow_build: bool) -> None:
147150
existing = shutil.which("mcp-language-server")
148-
if existing:
151+
wrapper = venv_bin_dir() / (
152+
"mcp-language-server.cmd"
153+
if platform.system() == "Windows"
154+
else "mcp-language-server"
155+
)
156+
if existing and Path(existing).resolve() != wrapper.resolve():
149157
install_wrapper("mcp-language-server", Path(existing))
150158
log(f"[ok] LSP MCP bridge: {existing}")
151159
return
152-
if not allow_build:
153-
raise RuntimeError(
154-
"mcp-language-server is missing and automatic build was disabled."
155-
)
156160

157161
require("go", "Install Go from https://go.dev/doc/install.")
158-
run(
159-
[
160-
"go",
161-
"install",
162-
f"{MCP_LANGUAGE_SERVER_MODULE}@{MCP_LANGUAGE_SERVER_VERSION}",
163-
]
164-
)
165162
go_path = subprocess.run(
166163
["go", "env", "GOPATH"],
167164
check=True,
@@ -173,9 +170,22 @@ def ensure_lsp_mcp(allow_build: bool) -> None:
173170
if platform.system() == "Windows"
174171
else "mcp-language-server"
175172
)
173+
if not executable.exists():
174+
if not allow_build:
175+
raise RuntimeError(
176+
"mcp-language-server is missing and automatic build was disabled."
177+
)
178+
run(
179+
[
180+
"go",
181+
"install",
182+
f"{MCP_LANGUAGE_SERVER_MODULE}@{MCP_LANGUAGE_SERVER_VERSION}",
183+
]
184+
)
176185
if not executable.exists():
177186
raise RuntimeError(f"Go installed bridge was not found at {executable}")
178187
install_wrapper("mcp-language-server", executable)
188+
log(f"[ok] LSP MCP bridge: {executable}")
179189

180190

181191
def ensure_mcp_runtime() -> None:

scripts/smoke_source_lsp.py

Lines changed: 43 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import argparse
77
import asyncio
8-
import os
8+
import tempfile
99
from contextlib import AsyncExitStack
1010
from pathlib import Path
1111

@@ -33,6 +33,7 @@
3333

3434
async def check_language(language: str) -> None:
3535
workspace, source_file = CASES[language]
36+
source_path = str((workspace / source_file).resolve())
3637
connection = MCPLanguageConfigService().get_language_server_config(
3738
language,
3839
str(workspace),
@@ -44,38 +45,53 @@ async def check_language(language: str) -> None:
4445
env=connection.get("env"),
4546
)
4647

47-
async with AsyncExitStack() as stack:
48-
errlog = stack.enter_context(open(os.devnull, "w", encoding="utf-8"))
49-
read_stream, write_stream = await stack.enter_async_context(
50-
stdio_client(parameters, errlog=errlog)
51-
)
52-
session = await stack.enter_async_context(
53-
ClientSession(read_stream, write_stream)
54-
)
55-
await session.initialize()
56-
tools = {tool.name for tool in (await session.list_tools()).tools}
57-
required = {"definition", "references", "diagnostics", "hover"}
58-
missing = required - tools
59-
if missing:
60-
raise RuntimeError(
61-
f"{language}: bridge is missing tools: {', '.join(sorted(missing))}"
62-
)
63-
result = await session.call_tool(
64-
"diagnostics",
65-
{"filePath": source_file},
66-
)
67-
if result.isError:
68-
detail = " ".join(
69-
getattr(item, "text", str(item)) for item in result.content
70-
)
71-
raise RuntimeError(f"{language}: diagnostics failed: {detail}")
48+
stage = "starting MCP bridge"
49+
print(f"[check] {language}: {stage}", flush=True)
50+
with tempfile.TemporaryFile(mode="w+", encoding="utf-8") as errlog:
51+
try:
52+
async with AsyncExitStack() as stack:
53+
read_stream, write_stream = await stack.enter_async_context(
54+
stdio_client(parameters, errlog=errlog)
55+
)
56+
session = await stack.enter_async_context(
57+
ClientSession(read_stream, write_stream)
58+
)
59+
stage = "initializing MCP session"
60+
async with asyncio.timeout(30):
61+
await session.initialize()
62+
stage = "listing MCP tools"
63+
async with asyncio.timeout(15):
64+
tools = {tool.name for tool in (await session.list_tools()).tools}
65+
required = {"definition", "references", "diagnostics", "hover"}
66+
missing = required - tools
67+
if missing:
68+
raise RuntimeError(
69+
f"{language}: bridge is missing tools: "
70+
f"{', '.join(sorted(missing))}"
71+
)
72+
stage = f"requesting diagnostics for {source_path}"
73+
async with asyncio.timeout(30):
74+
result = await session.call_tool(
75+
"diagnostics",
76+
{"filePath": source_path},
77+
)
78+
if result.isError:
79+
detail = " ".join(
80+
getattr(item, "text", str(item)) for item in result.content
81+
)
82+
raise RuntimeError(f"{language}: diagnostics failed: {detail}")
83+
except Exception as exc:
84+
errlog.seek(0)
85+
server_log = errlog.read().strip()
86+
detail = f"\nBridge stderr:\n{server_log}" if server_log else ""
87+
raise RuntimeError(f"{language}: failed while {stage}: {exc}{detail}") from exc
7288

7389
print(f"[ok] {language}: MCP handshake and diagnostics call succeeded")
7490

7591

7692
async def async_main(languages: list[str]) -> None:
7793
for language in languages:
78-
await asyncio.wait_for(check_language(language), timeout=90)
94+
await check_language(language)
7995

8096

8197
def main() -> None:

test/test_bootstrap.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import importlib.util
2+
from pathlib import Path
3+
4+
import pytest
5+
6+
7+
def _load_bootstrap_module():
8+
path = Path(__file__).resolve().parents[1] / "scripts" / "bootstrap.py"
9+
spec = importlib.util.spec_from_file_location("pure_auto_codeql_bootstrap", path)
10+
assert spec is not None
11+
assert spec.loader is not None
12+
module = importlib.util.module_from_spec(spec)
13+
spec.loader.exec_module(module)
14+
return module
15+
16+
17+
def test_install_wrapper_rejects_recursive_target(tmp_path: Path, monkeypatch) -> None:
18+
bootstrap = _load_bootstrap_module()
19+
bin_dir = tmp_path / "bin"
20+
monkeypatch.setattr(bootstrap, "venv_bin_dir", lambda: bin_dir)
21+
wrapper = bin_dir / "mcp-language-server"
22+
wrapper.parent.mkdir(parents=True)
23+
wrapper.write_text("#!/usr/bin/env sh\n", encoding="utf-8")
24+
25+
with pytest.raises(RuntimeError, match="recursive wrapper"):
26+
bootstrap.install_wrapper("mcp-language-server", wrapper)

0 commit comments

Comments
 (0)