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.
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ uv.lock
@@ -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,3 @@
1
+ """MCP server exposing read-only LSP navigation tools."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()