codex-lsp-mcp 0.1.0__tar.gz
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.
- codex_lsp_mcp-0.1.0/.gitignore +4 -0
- codex_lsp_mcp-0.1.0/PKG-INFO +46 -0
- codex_lsp_mcp-0.1.0/README.md +35 -0
- codex_lsp_mcp-0.1.0/pyproject.toml +26 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/__init__.py +3 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/config.py +81 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/lsp.py +215 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/manager.py +41 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/preview.py +34 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/root.py +25 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/server.py +101 -0
- codex_lsp_mcp-0.1.0/src/codex_lsp_mcp/session.py +439 -0
- codex_lsp_mcp-0.1.0/tests/test_config.py +70 -0
- codex_lsp_mcp-0.1.0/tests/test_lsp.py +431 -0
- codex_lsp_mcp-0.1.0/tests/test_manager.py +64 -0
- codex_lsp_mcp-0.1.0/tests/test_preview.py +28 -0
- codex_lsp_mcp-0.1.0/tests/test_root.py +35 -0
- codex_lsp_mcp-0.1.0/tests/test_server.py +171 -0
- codex_lsp_mcp-0.1.0/tests/test_session.py +480 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codex-lsp-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server exposing read-only LSP navigation tools for Codex
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: mcp>=1.0.0
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# codex-lsp-mcp
|
|
13
|
+
|
|
14
|
+
`codex-lsp-mcp` is a local MCP stdio server that exposes read-only LSP navigation tools to Codex.
|
|
15
|
+
|
|
16
|
+
First supported backend: `clangd`.
|
|
17
|
+
|
|
18
|
+
## Local development
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
uv run pytest
|
|
22
|
+
uvx --from . codex-lsp-mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Codex configuration
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
codex mcp add codex-lsp-mcp -- uvx --from git+https://github.com/SunJun8/codex-lsp-mcp.git@v0.1.0 codex-lsp-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Check registration:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
codex mcp get codex-lsp-mcp
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If `clangd` is not on Codex's `PATH`, set:
|
|
38
|
+
|
|
39
|
+
```toml
|
|
40
|
+
[mcp_servers.codex-lsp-mcp.env]
|
|
41
|
+
CLANGD_BIN = "/path/to/clangd"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The server discovers the closest `compile_commands.json` from the queried file path.
|
|
45
|
+
|
|
46
|
+
Tool coordinates follow the LSP convention: zero-based `line` and `character`.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# codex-lsp-mcp
|
|
2
|
+
|
|
3
|
+
`codex-lsp-mcp` is a local MCP stdio server that exposes read-only LSP navigation tools to Codex.
|
|
4
|
+
|
|
5
|
+
First supported backend: `clangd`.
|
|
6
|
+
|
|
7
|
+
## Local development
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
uv run pytest
|
|
11
|
+
uvx --from . codex-lsp-mcp
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Codex configuration
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
codex mcp add codex-lsp-mcp -- uvx --from git+https://github.com/SunJun8/codex-lsp-mcp.git@v0.1.0 codex-lsp-mcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Check registration:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
codex mcp get codex-lsp-mcp
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
If `clangd` is not on Codex's `PATH`, set:
|
|
27
|
+
|
|
28
|
+
```toml
|
|
29
|
+
[mcp_servers.codex-lsp-mcp.env]
|
|
30
|
+
CLANGD_BIN = "/path/to/clangd"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The server discovers the closest `compile_commands.json` from the queried file path.
|
|
34
|
+
|
|
35
|
+
Tool coordinates follow the LSP convention: zero-based `line` and `character`.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codex-lsp-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server exposing read-only LSP navigation tools for Codex"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"mcp>=1.0.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0.0",
|
|
18
|
+
"pytest-asyncio>=0.23.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
codex-lsp-mcp = "codex_lsp_mcp.server:main"
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "codex-lsp-mcp" / "config.toml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ServerConfig:
|
|
15
|
+
command: str
|
|
16
|
+
args: list[str]
|
|
17
|
+
extension_to_language: dict[str, str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class AppConfig:
|
|
22
|
+
servers: dict[str, ServerConfig]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_config() -> AppConfig:
|
|
26
|
+
return AppConfig(
|
|
27
|
+
servers={
|
|
28
|
+
"clangd": ServerConfig(
|
|
29
|
+
command="clangd",
|
|
30
|
+
args=["--background-index"],
|
|
31
|
+
extension_to_language={
|
|
32
|
+
".c": "c",
|
|
33
|
+
".h": "c",
|
|
34
|
+
".cpp": "cpp",
|
|
35
|
+
".cc": "cpp",
|
|
36
|
+
".cxx": "cpp",
|
|
37
|
+
".hpp": "cpp",
|
|
38
|
+
".hxx": "cpp",
|
|
39
|
+
".C": "cpp",
|
|
40
|
+
".H": "cpp",
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def load_config(env: dict[str, str] | None = None) -> AppConfig:
|
|
48
|
+
values = dict(os.environ if env is None else env)
|
|
49
|
+
config = default_config()
|
|
50
|
+
|
|
51
|
+
config_path = Path(values.get("CODEX_LSP_MCP_CONFIG", DEFAULT_CONFIG_PATH)).expanduser()
|
|
52
|
+
if config_path.exists():
|
|
53
|
+
config = _merge_config_file(config, config_path)
|
|
54
|
+
|
|
55
|
+
clangd = config.servers["clangd"]
|
|
56
|
+
if "CLANGD_BIN" in values:
|
|
57
|
+
clangd = replace(clangd, command=values["CLANGD_BIN"])
|
|
58
|
+
if "CLANGD_ARGS" in values:
|
|
59
|
+
clangd = replace(clangd, args=shlex.split(values["CLANGD_ARGS"]))
|
|
60
|
+
|
|
61
|
+
servers = dict(config.servers)
|
|
62
|
+
servers["clangd"] = clangd
|
|
63
|
+
return AppConfig(servers=servers)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _merge_config_file(config: AppConfig, path: Path) -> AppConfig:
|
|
67
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
servers = dict(config.servers)
|
|
69
|
+
|
|
70
|
+
for name, raw_server in data.get("servers", {}).items():
|
|
71
|
+
existing = servers.get(name, ServerConfig(command=name, args=[], extension_to_language={}))
|
|
72
|
+
extension_to_language = raw_server.get(
|
|
73
|
+
"extension_to_language", existing.extension_to_language
|
|
74
|
+
)
|
|
75
|
+
servers[name] = ServerConfig(
|
|
76
|
+
command=raw_server.get("command", existing.command),
|
|
77
|
+
args=list(raw_server.get("args", existing.args)),
|
|
78
|
+
extension_to_language=dict(extension_to_language),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return AppConfig(servers=servers)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
JsonObject = dict[str, Any]
|
|
11
|
+
ServerRequestHandler = Callable[[JsonObject], Any]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LspConnectionError(ConnectionError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LspProtocolError(RuntimeError):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def encode_message(message: JsonObject) -> bytes:
|
|
23
|
+
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
|
24
|
+
header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
|
|
25
|
+
return header + body
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def read_message(reader: asyncio.StreamReader) -> JsonObject:
|
|
29
|
+
headers: dict[str, str] = {}
|
|
30
|
+
while True:
|
|
31
|
+
line = await reader.readline()
|
|
32
|
+
if line == b"":
|
|
33
|
+
raise LspConnectionError("connection closed while reading headers")
|
|
34
|
+
if line in (b"\r\n", b"\n"):
|
|
35
|
+
break
|
|
36
|
+
if b":" not in line:
|
|
37
|
+
raise LspProtocolError(f"malformed header: {line.decode('ascii', errors='replace').strip()}")
|
|
38
|
+
key, value = line.decode("ascii").split(":", 1)
|
|
39
|
+
headers[key.lower()] = value.strip()
|
|
40
|
+
|
|
41
|
+
if "content-length" not in headers:
|
|
42
|
+
raise LspProtocolError("missing Content-Length header")
|
|
43
|
+
try:
|
|
44
|
+
length = int(headers["content-length"])
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise LspProtocolError("invalid Content-Length header") from exc
|
|
47
|
+
if length < 0:
|
|
48
|
+
raise LspProtocolError("invalid Content-Length header")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
body = await reader.readexactly(length)
|
|
52
|
+
except asyncio.IncompleteReadError as exc:
|
|
53
|
+
raise LspConnectionError("connection closed while reading body") from exc
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(body.decode("utf-8"))
|
|
57
|
+
except json.JSONDecodeError as exc:
|
|
58
|
+
raise LspProtocolError("invalid JSON-RPC message body") from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class LspClient:
|
|
62
|
+
def __init__(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
63
|
+
self._reader = reader
|
|
64
|
+
self._writer = writer
|
|
65
|
+
self._next_id = 1
|
|
66
|
+
self._pending: dict[int, asyncio.Future[JsonObject]] = {}
|
|
67
|
+
self._request_handlers: dict[str, ServerRequestHandler] = {}
|
|
68
|
+
self._notifications: asyncio.Queue[JsonObject] = asyncio.Queue()
|
|
69
|
+
self._notification_waiters: set[asyncio.Future[JsonObject]] = set()
|
|
70
|
+
self._reader_task: asyncio.Task[None] | None = None
|
|
71
|
+
self._closed_error: Exception | None = None
|
|
72
|
+
|
|
73
|
+
def start(self) -> None:
|
|
74
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
75
|
+
|
|
76
|
+
def set_request_handler(self, method: str, handler: ServerRequestHandler) -> None:
|
|
77
|
+
self._request_handlers[method] = handler
|
|
78
|
+
|
|
79
|
+
async def stop(self) -> None:
|
|
80
|
+
self._closed_error = LspConnectionError("LSP client stopped")
|
|
81
|
+
self._fail_pending(self._closed_error)
|
|
82
|
+
if self._reader_task is not None:
|
|
83
|
+
self._reader_task.cancel()
|
|
84
|
+
try:
|
|
85
|
+
await self._reader_task
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
pass
|
|
88
|
+
self._writer.close()
|
|
89
|
+
await self._writer.wait_closed()
|
|
90
|
+
|
|
91
|
+
async def request(self, method: str, params: JsonObject | None = None) -> Any:
|
|
92
|
+
self._raise_if_closed()
|
|
93
|
+
request_id = self._next_id
|
|
94
|
+
self._next_id += 1
|
|
95
|
+
loop = asyncio.get_running_loop()
|
|
96
|
+
future: asyncio.Future[JsonObject] = loop.create_future()
|
|
97
|
+
self._pending[request_id] = future
|
|
98
|
+
try:
|
|
99
|
+
await self._write({"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}})
|
|
100
|
+
response = await future
|
|
101
|
+
if "error" in response:
|
|
102
|
+
raise RuntimeError(response["error"])
|
|
103
|
+
return response.get("result")
|
|
104
|
+
except asyncio.CancelledError:
|
|
105
|
+
self._pending.pop(request_id, None)
|
|
106
|
+
if self._closed_error is None:
|
|
107
|
+
await self._write_unchecked({"jsonrpc": "2.0", "method": "$/cancelRequest", "params": {"id": request_id}})
|
|
108
|
+
raise
|
|
109
|
+
finally:
|
|
110
|
+
self._pending.pop(request_id, None)
|
|
111
|
+
|
|
112
|
+
async def notify(self, method: str, params: JsonObject | None = None) -> None:
|
|
113
|
+
self._raise_if_closed()
|
|
114
|
+
await self._write({"jsonrpc": "2.0", "method": method, "params": params or {}})
|
|
115
|
+
|
|
116
|
+
async def next_notification(self) -> JsonObject:
|
|
117
|
+
self._raise_if_closed()
|
|
118
|
+
if not self._notifications.empty():
|
|
119
|
+
return await self._notifications.get()
|
|
120
|
+
|
|
121
|
+
loop = asyncio.get_running_loop()
|
|
122
|
+
future: asyncio.Future[JsonObject] = loop.create_future()
|
|
123
|
+
self._notification_waiters.add(future)
|
|
124
|
+
try:
|
|
125
|
+
return await future
|
|
126
|
+
finally:
|
|
127
|
+
self._notification_waiters.discard(future)
|
|
128
|
+
|
|
129
|
+
async def _write(self, message: JsonObject) -> None:
|
|
130
|
+
self._raise_if_closed()
|
|
131
|
+
await self._write_unchecked(message)
|
|
132
|
+
|
|
133
|
+
async def _write_unchecked(self, message: JsonObject) -> None:
|
|
134
|
+
self._writer.write(encode_message(message))
|
|
135
|
+
await self._writer.drain()
|
|
136
|
+
|
|
137
|
+
async def _read_loop(self) -> None:
|
|
138
|
+
try:
|
|
139
|
+
while True:
|
|
140
|
+
message = await read_message(self._reader)
|
|
141
|
+
await self._handle_message(message)
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
raise
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
self._closed_error = exc
|
|
146
|
+
self._fail_pending(exc)
|
|
147
|
+
|
|
148
|
+
async def _handle_message(self, message: JsonObject) -> None:
|
|
149
|
+
message_id = message.get("id")
|
|
150
|
+
if "id" in message and "method" in message:
|
|
151
|
+
await self._handle_server_request(message_id, message)
|
|
152
|
+
elif isinstance(message_id, int) and message_id in self._pending:
|
|
153
|
+
future = self._pending.pop(message_id)
|
|
154
|
+
if not future.done():
|
|
155
|
+
future.set_result(message)
|
|
156
|
+
elif "id" in message:
|
|
157
|
+
return
|
|
158
|
+
else:
|
|
159
|
+
self._put_notification(message)
|
|
160
|
+
|
|
161
|
+
async def _handle_server_request(self, message_id: Any, message: JsonObject) -> None:
|
|
162
|
+
method = message.get("method")
|
|
163
|
+
handler = self._request_handlers.get(method)
|
|
164
|
+
if handler is None:
|
|
165
|
+
await self._write(
|
|
166
|
+
{
|
|
167
|
+
"jsonrpc": "2.0",
|
|
168
|
+
"id": message_id,
|
|
169
|
+
"error": {"code": -32601, "message": "Method not found"},
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
result = handler(message.get("params", {}))
|
|
176
|
+
if inspect.isawaitable(result):
|
|
177
|
+
result = await result
|
|
178
|
+
except Exception as exc:
|
|
179
|
+
await self._write(
|
|
180
|
+
{
|
|
181
|
+
"jsonrpc": "2.0",
|
|
182
|
+
"id": message_id,
|
|
183
|
+
"error": {"code": -32000, "message": str(exc)},
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
await self._write({"jsonrpc": "2.0", "id": message_id, "result": result})
|
|
189
|
+
|
|
190
|
+
def _raise_if_closed(self) -> None:
|
|
191
|
+
if self._closed_error is not None:
|
|
192
|
+
raise self._closed_error
|
|
193
|
+
|
|
194
|
+
def _fail_pending(self, exc: Exception) -> None:
|
|
195
|
+
pending = list(self._pending.values())
|
|
196
|
+
self._pending.clear()
|
|
197
|
+
for future in pending:
|
|
198
|
+
if not future.done():
|
|
199
|
+
future.set_exception(exc)
|
|
200
|
+
self._fail_notification_waiters(exc)
|
|
201
|
+
|
|
202
|
+
def _put_notification(self, message: JsonObject) -> None:
|
|
203
|
+
for future in list(self._notification_waiters):
|
|
204
|
+
self._notification_waiters.discard(future)
|
|
205
|
+
if not future.done():
|
|
206
|
+
future.set_result(message)
|
|
207
|
+
return
|
|
208
|
+
self._notifications.put_nowait(message)
|
|
209
|
+
|
|
210
|
+
def _fail_notification_waiters(self, exc: Exception) -> None:
|
|
211
|
+
waiters = list(self._notification_waiters)
|
|
212
|
+
self._notification_waiters.clear()
|
|
213
|
+
for future in waiters:
|
|
214
|
+
if not future.done():
|
|
215
|
+
future.set_exception(exc)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
from .config import AppConfig, ServerConfig
|
|
7
|
+
from .root import discover_root
|
|
8
|
+
from .session import ClangdSession
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
SessionFactory = Callable[[Path, ServerConfig], ClangdSession]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SessionManager:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
config: AppConfig,
|
|
18
|
+
fallback_root: str | Path,
|
|
19
|
+
session_factory: SessionFactory = ClangdSession,
|
|
20
|
+
) -> None:
|
|
21
|
+
self.config = config
|
|
22
|
+
self.fallback_root = Path(fallback_root).expanduser().resolve()
|
|
23
|
+
self.session_factory = session_factory
|
|
24
|
+
self.sessions: dict[tuple[str, Path], ClangdSession] = {}
|
|
25
|
+
|
|
26
|
+
def get_session(self, file_path: str | Path) -> ClangdSession:
|
|
27
|
+
path = Path(file_path).expanduser().resolve()
|
|
28
|
+
server_name, _language_id = self.language_for(path)
|
|
29
|
+
root = discover_root(path, self.fallback_root)
|
|
30
|
+
key = (server_name, root)
|
|
31
|
+
if key not in self.sessions:
|
|
32
|
+
self.sessions[key] = self.session_factory(root, self.config.servers[server_name])
|
|
33
|
+
return self.sessions[key]
|
|
34
|
+
|
|
35
|
+
def language_for(self, file_path: str | Path) -> tuple[str, str]:
|
|
36
|
+
path = Path(file_path)
|
|
37
|
+
for server_name, server_config in self.config.servers.items():
|
|
38
|
+
language_id = server_config.extension_to_language.get(path.suffix)
|
|
39
|
+
if language_id:
|
|
40
|
+
return server_name, language_id
|
|
41
|
+
raise ValueError(f"unsupported file extension: {path.suffix}")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def make_position(line: int, character: int) -> dict[str, int]:
|
|
8
|
+
if line < 0 or character < 0:
|
|
9
|
+
raise ValueError("line and character must be zero-based non-negative integers")
|
|
10
|
+
return {"line": line, "character": character}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def lsp_range_to_user_range(lsp_range: dict[str, Any]) -> dict[str, int]:
|
|
14
|
+
start = lsp_range["start"]
|
|
15
|
+
end = lsp_range["end"]
|
|
16
|
+
return {
|
|
17
|
+
"line": start["line"],
|
|
18
|
+
"character": start["character"],
|
|
19
|
+
"end_line": end["line"],
|
|
20
|
+
"end_character": end["character"],
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_preview(path: str | Path, line: int) -> str:
|
|
25
|
+
if line < 1:
|
|
26
|
+
return ""
|
|
27
|
+
file_path = Path(path)
|
|
28
|
+
try:
|
|
29
|
+
lines = file_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
30
|
+
except FileNotFoundError:
|
|
31
|
+
return ""
|
|
32
|
+
if line > len(lines):
|
|
33
|
+
return ""
|
|
34
|
+
return lines[line - 1].strip()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ROOT_MARKER_GROUPS = (
|
|
7
|
+
("compile_commands.json",),
|
|
8
|
+
("compile_flags.txt",),
|
|
9
|
+
(".clangd",),
|
|
10
|
+
(".git", ".repo"),
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def discover_root(file_path: str | Path, fallback: str | Path) -> Path:
|
|
15
|
+
path = Path(file_path).expanduser().resolve()
|
|
16
|
+
current = path.parent if path.is_file() or path.suffix else path
|
|
17
|
+
fallback_path = Path(fallback).expanduser().resolve()
|
|
18
|
+
ancestors = [current, *current.parents]
|
|
19
|
+
|
|
20
|
+
for markers in ROOT_MARKER_GROUPS:
|
|
21
|
+
for directory in ancestors:
|
|
22
|
+
if any((directory / marker).exists() for marker in markers):
|
|
23
|
+
return directory
|
|
24
|
+
|
|
25
|
+
return fallback_path
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from .config import load_config
|
|
9
|
+
from .manager import SessionManager
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
JsonObject = dict[str, Any]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ToolHandlers:
|
|
16
|
+
def __init__(self, manager: SessionManager) -> None:
|
|
17
|
+
self.manager = manager
|
|
18
|
+
|
|
19
|
+
async def definition(self, file: str, line: int, character: int) -> JsonObject:
|
|
20
|
+
"""Return definition locations for a zero-based LSP position."""
|
|
21
|
+
path, language_id, session = self._resolve_file(file)
|
|
22
|
+
return await session.definition(path, language_id, line, character)
|
|
23
|
+
|
|
24
|
+
async def references(
|
|
25
|
+
self,
|
|
26
|
+
file: str,
|
|
27
|
+
line: int,
|
|
28
|
+
character: int,
|
|
29
|
+
include_declaration: bool = False,
|
|
30
|
+
) -> JsonObject:
|
|
31
|
+
"""Return references for a zero-based LSP position."""
|
|
32
|
+
path, language_id, session = self._resolve_file(file)
|
|
33
|
+
return await session.references(
|
|
34
|
+
path,
|
|
35
|
+
language_id,
|
|
36
|
+
line,
|
|
37
|
+
character,
|
|
38
|
+
include_declaration,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
async def hover(self, file: str, line: int, character: int) -> JsonObject:
|
|
42
|
+
"""Return hover text for a zero-based LSP position."""
|
|
43
|
+
path, language_id, session = self._resolve_file(file)
|
|
44
|
+
return await session.hover(path, language_id, line, character)
|
|
45
|
+
|
|
46
|
+
async def diagnostics(self, file: str) -> JsonObject:
|
|
47
|
+
"""Return diagnostics for a file."""
|
|
48
|
+
path, language_id, session = self._resolve_file(file)
|
|
49
|
+
return await session.diagnostics(path, language_id)
|
|
50
|
+
|
|
51
|
+
async def document_symbols(self, file: str) -> JsonObject:
|
|
52
|
+
"""Return document symbols for a file."""
|
|
53
|
+
path, language_id, session = self._resolve_file(file)
|
|
54
|
+
return await session.document_symbols(path, language_id)
|
|
55
|
+
|
|
56
|
+
async def workspace_symbols(
|
|
57
|
+
self,
|
|
58
|
+
query: str,
|
|
59
|
+
root_hint: str | None = None,
|
|
60
|
+
) -> JsonObject:
|
|
61
|
+
"""Return workspace symbols, optionally scoped by a root hint."""
|
|
62
|
+
if root_hint is not None:
|
|
63
|
+
hint = Path(root_hint).expanduser().resolve()
|
|
64
|
+
session_path = hint if hint.is_file() else hint / ".codex_lsp_workspace_hint.c"
|
|
65
|
+
session = self.manager.get_session(session_path)
|
|
66
|
+
else:
|
|
67
|
+
try:
|
|
68
|
+
session = next(iter(self.manager.sessions.values()))
|
|
69
|
+
except StopIteration:
|
|
70
|
+
session_path = self.manager.fallback_root / ".codex_lsp_workspace_hint.c"
|
|
71
|
+
session = self.manager.get_session(session_path)
|
|
72
|
+
return await session.workspace_symbols(query)
|
|
73
|
+
|
|
74
|
+
def _resolve_file(self, file: str) -> tuple[Path, str, Any]:
|
|
75
|
+
path = Path(file).expanduser().resolve()
|
|
76
|
+
if not path.exists():
|
|
77
|
+
raise FileNotFoundError(path)
|
|
78
|
+
|
|
79
|
+
_server_name, language_id = self.manager.language_for(path)
|
|
80
|
+
session = self.manager.get_session(path)
|
|
81
|
+
return path, language_id, session
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_mcp() -> FastMCP:
|
|
85
|
+
config = load_config()
|
|
86
|
+
manager = SessionManager(config, fallback_root=Path.cwd())
|
|
87
|
+
handlers = ToolHandlers(manager)
|
|
88
|
+
mcp = FastMCP("codex-lsp-mcp")
|
|
89
|
+
|
|
90
|
+
mcp.tool()(handlers.definition)
|
|
91
|
+
mcp.tool()(handlers.references)
|
|
92
|
+
mcp.tool()(handlers.hover)
|
|
93
|
+
mcp.tool()(handlers.diagnostics)
|
|
94
|
+
mcp.tool()(handlers.document_symbols)
|
|
95
|
+
mcp.tool()(handlers.workspace_symbols)
|
|
96
|
+
|
|
97
|
+
return mcp
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main() -> None:
|
|
101
|
+
build_mcp().run()
|