cpp-debug-mcp 0.1.1__py3-none-any.whl
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.
- cpp_debug_mcp/__init__.py +1 -0
- cpp_debug_mcp/__main__.py +6 -0
- cpp_debug_mcp/analysis/__init__.py +0 -0
- cpp_debug_mcp/analysis/correlator.py +260 -0
- cpp_debug_mcp/gdb/__init__.py +0 -0
- cpp_debug_mcp/gdb/controller.py +136 -0
- cpp_debug_mcp/gdb/session.py +146 -0
- cpp_debug_mcp/lsp/__init__.py +0 -0
- cpp_debug_mcp/lsp/client.py +210 -0
- cpp_debug_mcp/lsp/protocol.py +213 -0
- cpp_debug_mcp/lsp/session.py +91 -0
- cpp_debug_mcp/server.py +52 -0
- cpp_debug_mcp/tools/__init__.py +0 -0
- cpp_debug_mcp/tools/combined_tools.py +110 -0
- cpp_debug_mcp/tools/fmt.py +301 -0
- cpp_debug_mcp/tools/gdb_tools.py +395 -0
- cpp_debug_mcp/tools/lsp_tools.py +265 -0
- cpp_debug_mcp-0.1.1.dist-info/METADATA +187 -0
- cpp_debug_mcp-0.1.1.dist-info/RECORD +22 -0
- cpp_debug_mcp-0.1.1.dist-info/WHEEL +5 -0
- cpp_debug_mcp-0.1.1.dist-info/licenses/LICENSE +21 -0
- cpp_debug_mcp-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Async JSON-RPC client for clangd over STDIO."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LspError(Exception):
|
|
14
|
+
"""Error from LSP server."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClangdClient:
|
|
18
|
+
"""Async client that communicates with clangd via STDIO using LSP protocol."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, clangd_path: str | None = None):
|
|
21
|
+
self._clangd_path = clangd_path or os.environ.get("CLANGD_PATH", "clangd")
|
|
22
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
23
|
+
self._request_id = 0
|
|
24
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
25
|
+
self._notifications: dict[str, list[dict]] = {}
|
|
26
|
+
self._notification_events: dict[str, asyncio.Event] = {}
|
|
27
|
+
self._reader_task: asyncio.Task | None = None
|
|
28
|
+
self._initialized = False
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_running(self) -> bool:
|
|
32
|
+
return self._process is not None and self._process.returncode is None
|
|
33
|
+
|
|
34
|
+
async def start(
|
|
35
|
+
self,
|
|
36
|
+
project_root: str,
|
|
37
|
+
compile_commands_dir: str = "",
|
|
38
|
+
) -> dict:
|
|
39
|
+
"""Launch clangd and perform LSP initialization handshake."""
|
|
40
|
+
cmd = [self._clangd_path, "--log=error"]
|
|
41
|
+
if compile_commands_dir:
|
|
42
|
+
cmd.append(f"--compile-commands-dir={compile_commands_dir}")
|
|
43
|
+
|
|
44
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
45
|
+
*cmd,
|
|
46
|
+
stdin=asyncio.subprocess.PIPE,
|
|
47
|
+
stdout=asyncio.subprocess.PIPE,
|
|
48
|
+
stderr=asyncio.subprocess.PIPE,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
self._reader_task = asyncio.create_task(self._read_loop())
|
|
52
|
+
|
|
53
|
+
from .protocol import make_initialize_params
|
|
54
|
+
result = await self.send_request("initialize", make_initialize_params(project_root))
|
|
55
|
+
await self.send_notification("initialized", {})
|
|
56
|
+
self._initialized = True
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
async def send_request(self, method: str, params: dict, timeout: float = 30.0) -> dict:
|
|
60
|
+
"""Send a JSON-RPC request and wait for the response."""
|
|
61
|
+
if not self._process or not self._process.stdin:
|
|
62
|
+
raise LspError("clangd not started")
|
|
63
|
+
|
|
64
|
+
self._request_id += 1
|
|
65
|
+
req_id = self._request_id
|
|
66
|
+
|
|
67
|
+
message = {
|
|
68
|
+
"jsonrpc": "2.0",
|
|
69
|
+
"id": req_id,
|
|
70
|
+
"method": method,
|
|
71
|
+
"params": params,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
future = asyncio.get_event_loop().create_future()
|
|
75
|
+
self._pending[req_id] = future
|
|
76
|
+
|
|
77
|
+
self._write_message(message)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
return await asyncio.wait_for(future, timeout=timeout)
|
|
81
|
+
except asyncio.TimeoutError:
|
|
82
|
+
self._pending.pop(req_id, None)
|
|
83
|
+
raise LspError(f"Request timed out: {method}")
|
|
84
|
+
|
|
85
|
+
async def send_notification(self, method: str, params: dict) -> None:
|
|
86
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
87
|
+
if not self._process or not self._process.stdin:
|
|
88
|
+
raise LspError("clangd not started")
|
|
89
|
+
|
|
90
|
+
message = {
|
|
91
|
+
"jsonrpc": "2.0",
|
|
92
|
+
"method": method,
|
|
93
|
+
"params": params,
|
|
94
|
+
}
|
|
95
|
+
self._write_message(message)
|
|
96
|
+
|
|
97
|
+
async def wait_for_notification(
|
|
98
|
+
self, method: str, timeout: float = 15.0
|
|
99
|
+
) -> dict | None:
|
|
100
|
+
"""Wait for a specific notification from clangd."""
|
|
101
|
+
if method in self._notifications and self._notifications[method]:
|
|
102
|
+
return self._notifications[method].pop(0)
|
|
103
|
+
|
|
104
|
+
event = self._notification_events.setdefault(method, asyncio.Event())
|
|
105
|
+
event.clear()
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
await asyncio.wait_for(event.wait(), timeout=timeout)
|
|
109
|
+
if method in self._notifications and self._notifications[method]:
|
|
110
|
+
return self._notifications[method].pop(0)
|
|
111
|
+
except asyncio.TimeoutError:
|
|
112
|
+
pass
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
async def stop(self) -> None:
|
|
116
|
+
"""Shutdown clangd gracefully."""
|
|
117
|
+
if not self._process:
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
if self._initialized:
|
|
122
|
+
await self.send_request("shutdown", {}, timeout=5.0)
|
|
123
|
+
await self.send_notification("exit", {})
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
if self._reader_task:
|
|
128
|
+
self._reader_task.cancel()
|
|
129
|
+
try:
|
|
130
|
+
await self._reader_task
|
|
131
|
+
except asyncio.CancelledError:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
if self._process and self._process.returncode is None:
|
|
135
|
+
self._process.terminate()
|
|
136
|
+
try:
|
|
137
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
138
|
+
except asyncio.TimeoutError:
|
|
139
|
+
self._process.kill()
|
|
140
|
+
|
|
141
|
+
self._process = None
|
|
142
|
+
self._initialized = False
|
|
143
|
+
self._pending.clear()
|
|
144
|
+
self._notifications.clear()
|
|
145
|
+
|
|
146
|
+
def _write_message(self, message: dict) -> None:
|
|
147
|
+
"""Write an LSP message with Content-Length header."""
|
|
148
|
+
body = json.dumps(message)
|
|
149
|
+
header = f"Content-Length: {len(body)}\r\n\r\n"
|
|
150
|
+
data = (header + body).encode("utf-8")
|
|
151
|
+
self._process.stdin.write(data)
|
|
152
|
+
|
|
153
|
+
async def _read_loop(self) -> None:
|
|
154
|
+
"""Background task that reads and dispatches messages from clangd."""
|
|
155
|
+
try:
|
|
156
|
+
while self._process and self._process.returncode is None:
|
|
157
|
+
message = await self._read_message()
|
|
158
|
+
if message is None:
|
|
159
|
+
break
|
|
160
|
+
self._dispatch(message)
|
|
161
|
+
except asyncio.CancelledError:
|
|
162
|
+
pass
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.error("LSP read loop error: %s", e)
|
|
165
|
+
|
|
166
|
+
async def _read_message(self) -> dict | None:
|
|
167
|
+
"""Read one LSP message from clangd stdout."""
|
|
168
|
+
stdout = self._process.stdout
|
|
169
|
+
if not stdout:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
# Read headers
|
|
173
|
+
content_length = 0
|
|
174
|
+
while True:
|
|
175
|
+
line = await stdout.readline()
|
|
176
|
+
if not line:
|
|
177
|
+
return None
|
|
178
|
+
line_str = line.decode("utf-8").strip()
|
|
179
|
+
if not line_str:
|
|
180
|
+
break
|
|
181
|
+
if line_str.startswith("Content-Length:"):
|
|
182
|
+
content_length = int(line_str.split(":")[1].strip())
|
|
183
|
+
|
|
184
|
+
if content_length == 0:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
body = await stdout.readexactly(content_length)
|
|
188
|
+
return json.loads(body.decode("utf-8"))
|
|
189
|
+
|
|
190
|
+
def _dispatch(self, message: dict) -> None:
|
|
191
|
+
"""Route a received message to the appropriate handler."""
|
|
192
|
+
if "id" in message and "id" in message:
|
|
193
|
+
# Response to a request
|
|
194
|
+
req_id = message["id"]
|
|
195
|
+
future = self._pending.pop(req_id, None)
|
|
196
|
+
if future and not future.done():
|
|
197
|
+
if "error" in message:
|
|
198
|
+
future.set_exception(
|
|
199
|
+
LspError(f"LSP error: {message['error'].get('message', '')}")
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
future.set_result(message.get("result", {}))
|
|
203
|
+
elif "method" in message and "id" not in message:
|
|
204
|
+
# Notification from server
|
|
205
|
+
method = message["method"]
|
|
206
|
+
params = message.get("params", {})
|
|
207
|
+
self._notifications.setdefault(method, []).append(params)
|
|
208
|
+
event = self._notification_events.get(method)
|
|
209
|
+
if event:
|
|
210
|
+
event.set()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""LSP message construction helpers and response parsers."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class DiagnosticInfo:
|
|
9
|
+
file: str
|
|
10
|
+
line: int
|
|
11
|
+
column: int
|
|
12
|
+
end_line: int
|
|
13
|
+
end_column: int
|
|
14
|
+
severity: str
|
|
15
|
+
message: str
|
|
16
|
+
source: str
|
|
17
|
+
|
|
18
|
+
def to_dict(self) -> dict:
|
|
19
|
+
return {
|
|
20
|
+
"file": self.file,
|
|
21
|
+
"line": self.line,
|
|
22
|
+
"column": self.column,
|
|
23
|
+
"severity": self.severity,
|
|
24
|
+
"message": self.message,
|
|
25
|
+
"source": self.source,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class LocationInfo:
|
|
31
|
+
file: str
|
|
32
|
+
line: int
|
|
33
|
+
column: int
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict:
|
|
36
|
+
return {"file": self.file, "line": self.line, "column": self.column}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class HoverInfo:
|
|
41
|
+
contents: str
|
|
42
|
+
language: str = ""
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict:
|
|
45
|
+
return {"contents": self.contents, "language": self.language}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class SymbolInfo:
|
|
50
|
+
name: str
|
|
51
|
+
kind: int
|
|
52
|
+
line: int
|
|
53
|
+
column: int
|
|
54
|
+
end_line: int
|
|
55
|
+
end_column: int
|
|
56
|
+
children: list = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
result = {
|
|
60
|
+
"name": self.name,
|
|
61
|
+
"kind": SYMBOL_KIND_NAMES.get(self.kind, str(self.kind)),
|
|
62
|
+
"line": self.line,
|
|
63
|
+
"column": self.column,
|
|
64
|
+
}
|
|
65
|
+
if self.children:
|
|
66
|
+
result["children"] = [c.to_dict() for c in self.children]
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
SYMBOL_KIND_NAMES = {
|
|
71
|
+
1: "File", 2: "Module", 3: "Namespace", 4: "Package", 5: "Class",
|
|
72
|
+
6: "Method", 7: "Property", 8: "Field", 9: "Constructor", 10: "Enum",
|
|
73
|
+
11: "Interface", 12: "Function", 13: "Variable", 14: "Constant",
|
|
74
|
+
15: "String", 16: "Number", 17: "Boolean", 18: "Array", 19: "Object",
|
|
75
|
+
20: "Key", 21: "Null", 22: "EnumMember", 23: "Struct", 24: "Event",
|
|
76
|
+
25: "Operator", 26: "TypeParameter",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
SEVERITY_NAMES = {1: "error", 2: "warning", 3: "info", 4: "hint"}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def file_uri(path: str) -> str:
|
|
83
|
+
"""Convert a file path to a file:// URI."""
|
|
84
|
+
return Path(path).resolve().as_uri()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def uri_to_path(uri: str) -> str:
|
|
88
|
+
"""Convert a file:// URI to a path."""
|
|
89
|
+
if uri.startswith("file://"):
|
|
90
|
+
return uri[7:]
|
|
91
|
+
return uri
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def make_initialize_params(root_path: str) -> dict:
|
|
95
|
+
"""Construct initialize request params."""
|
|
96
|
+
return {
|
|
97
|
+
"processId": None,
|
|
98
|
+
"rootUri": file_uri(root_path),
|
|
99
|
+
"capabilities": {
|
|
100
|
+
"textDocument": {
|
|
101
|
+
"hover": {"contentFormat": ["plaintext", "markdown"]},
|
|
102
|
+
"definition": {"linkSupport": False},
|
|
103
|
+
"references": {},
|
|
104
|
+
"documentSymbol": {
|
|
105
|
+
"hierarchicalDocumentSymbolSupport": True,
|
|
106
|
+
},
|
|
107
|
+
"signatureHelp": {
|
|
108
|
+
"signatureInformation": {
|
|
109
|
+
"parameterInformation": {"labelOffsetSupport": True},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
"publishDiagnostics": {"relatedInformation": True},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_did_open(file_path: str, content: str, language_id: str = "cpp") -> dict:
|
|
119
|
+
"""Construct textDocument/didOpen notification params."""
|
|
120
|
+
return {
|
|
121
|
+
"textDocument": {
|
|
122
|
+
"uri": file_uri(file_path),
|
|
123
|
+
"languageId": language_id,
|
|
124
|
+
"version": 1,
|
|
125
|
+
"text": content,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def make_text_document_position(file_path: str, line: int, column: int) -> dict:
|
|
131
|
+
"""Construct TextDocumentPositionParams (0-indexed line/column)."""
|
|
132
|
+
return {
|
|
133
|
+
"textDocument": {"uri": file_uri(file_path)},
|
|
134
|
+
"position": {"line": line, "character": column},
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def make_reference_params(file_path: str, line: int, column: int) -> dict:
|
|
139
|
+
"""Construct ReferenceParams."""
|
|
140
|
+
params = make_text_document_position(file_path, line, column)
|
|
141
|
+
params["context"] = {"includeDeclaration": True}
|
|
142
|
+
return params
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_diagnostic(raw: dict, file_path: str = "") -> DiagnosticInfo:
|
|
146
|
+
"""Parse a single LSP diagnostic."""
|
|
147
|
+
range_ = raw.get("range", {})
|
|
148
|
+
start = range_.get("start", {})
|
|
149
|
+
end = range_.get("end", {})
|
|
150
|
+
return DiagnosticInfo(
|
|
151
|
+
file=file_path,
|
|
152
|
+
line=start.get("line", 0),
|
|
153
|
+
column=start.get("character", 0),
|
|
154
|
+
end_line=end.get("line", 0),
|
|
155
|
+
end_column=end.get("character", 0),
|
|
156
|
+
severity=SEVERITY_NAMES.get(raw.get("severity", 0), "unknown"),
|
|
157
|
+
message=raw.get("message", ""),
|
|
158
|
+
source=raw.get("source", ""),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_hover(raw: dict | None) -> HoverInfo | None:
|
|
163
|
+
"""Parse a hover response."""
|
|
164
|
+
if not raw:
|
|
165
|
+
return None
|
|
166
|
+
contents = raw.get("contents", "")
|
|
167
|
+
if isinstance(contents, dict):
|
|
168
|
+
return HoverInfo(
|
|
169
|
+
contents=contents.get("value", ""),
|
|
170
|
+
language=contents.get("language", ""),
|
|
171
|
+
)
|
|
172
|
+
if isinstance(contents, str):
|
|
173
|
+
return HoverInfo(contents=contents)
|
|
174
|
+
if isinstance(contents, list):
|
|
175
|
+
parts = []
|
|
176
|
+
lang = ""
|
|
177
|
+
for item in contents:
|
|
178
|
+
if isinstance(item, dict):
|
|
179
|
+
parts.append(item.get("value", ""))
|
|
180
|
+
lang = item.get("language", lang)
|
|
181
|
+
else:
|
|
182
|
+
parts.append(str(item))
|
|
183
|
+
return HoverInfo(contents="\n".join(parts), language=lang)
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def parse_location(raw: dict) -> LocationInfo:
|
|
188
|
+
"""Parse a Location object."""
|
|
189
|
+
uri = raw.get("uri", raw.get("targetUri", ""))
|
|
190
|
+
range_ = raw.get("range", raw.get("targetSelectionRange", {}))
|
|
191
|
+
start = range_.get("start", {})
|
|
192
|
+
return LocationInfo(
|
|
193
|
+
file=uri_to_path(uri),
|
|
194
|
+
line=start.get("line", 0),
|
|
195
|
+
column=start.get("character", 0),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_document_symbol(raw: dict) -> SymbolInfo:
|
|
200
|
+
"""Parse a DocumentSymbol."""
|
|
201
|
+
range_ = raw.get("range", raw.get("location", {}).get("range", {}))
|
|
202
|
+
start = range_.get("start", {})
|
|
203
|
+
end = range_.get("end", {})
|
|
204
|
+
children = [parse_document_symbol(c) for c in raw.get("children", [])]
|
|
205
|
+
return SymbolInfo(
|
|
206
|
+
name=raw.get("name", ""),
|
|
207
|
+
kind=raw.get("kind", 0),
|
|
208
|
+
line=start.get("line", 0),
|
|
209
|
+
column=start.get("character", 0),
|
|
210
|
+
end_line=end.get("line", 0),
|
|
211
|
+
end_column=end.get("character", 0),
|
|
212
|
+
children=children,
|
|
213
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""LSP/clangd session lifecycle manager."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from .client import ClangdClient, LspError
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
MAX_SESSIONS = 2
|
|
12
|
+
INACTIVITY_TIMEOUT = 1800 # 30 minutes
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LspSessionManager:
|
|
16
|
+
"""Manages multiple clangd LSP sessions."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, max_sessions: int = MAX_SESSIONS):
|
|
19
|
+
self._sessions: dict[str, ClangdClient] = {}
|
|
20
|
+
self._last_activity: dict[str, float] = {}
|
|
21
|
+
self._opened_files: dict[str, set[str]] = {} # session_id -> set of opened URIs
|
|
22
|
+
self._max_sessions = max_sessions
|
|
23
|
+
|
|
24
|
+
async def create_session(
|
|
25
|
+
self,
|
|
26
|
+
project_root: str,
|
|
27
|
+
compile_commands_dir: str = "",
|
|
28
|
+
) -> tuple[str, dict]:
|
|
29
|
+
"""Create a new clangd session. Returns (session_id, server_capabilities)."""
|
|
30
|
+
await self._cleanup_stale()
|
|
31
|
+
|
|
32
|
+
if len(self._sessions) >= self._max_sessions:
|
|
33
|
+
raise LspError(
|
|
34
|
+
f"Maximum LSP sessions ({self._max_sessions}) reached. "
|
|
35
|
+
"End an existing session first."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
session_id = str(uuid.uuid4())[:8]
|
|
39
|
+
client = ClangdClient()
|
|
40
|
+
|
|
41
|
+
capabilities = await client.start(project_root, compile_commands_dir)
|
|
42
|
+
self._sessions[session_id] = client
|
|
43
|
+
self._last_activity[session_id] = time.time()
|
|
44
|
+
self._opened_files[session_id] = set()
|
|
45
|
+
|
|
46
|
+
return session_id, capabilities
|
|
47
|
+
|
|
48
|
+
def get_session(self, session_id: str) -> ClangdClient:
|
|
49
|
+
"""Get a session by ID."""
|
|
50
|
+
if session_id not in self._sessions:
|
|
51
|
+
raise LspError(f"LSP session not found: {session_id}")
|
|
52
|
+
self._last_activity[session_id] = time.time()
|
|
53
|
+
return self._sessions[session_id]
|
|
54
|
+
|
|
55
|
+
def get_opened_files(self, session_id: str) -> set[str]:
|
|
56
|
+
"""Get the set of file URIs opened in a session."""
|
|
57
|
+
return self._opened_files.get(session_id, set())
|
|
58
|
+
|
|
59
|
+
def mark_file_opened(self, session_id: str, uri: str) -> None:
|
|
60
|
+
"""Mark a file URI as opened in a session."""
|
|
61
|
+
self._opened_files.setdefault(session_id, set()).add(uri)
|
|
62
|
+
|
|
63
|
+
async def destroy_session(self, session_id: str) -> None:
|
|
64
|
+
"""End and clean up a specific session."""
|
|
65
|
+
client = self._sessions.pop(session_id, None)
|
|
66
|
+
self._last_activity.pop(session_id, None)
|
|
67
|
+
self._opened_files.pop(session_id, None)
|
|
68
|
+
if client:
|
|
69
|
+
await client.stop()
|
|
70
|
+
|
|
71
|
+
async def destroy_all(self) -> None:
|
|
72
|
+
"""End all sessions."""
|
|
73
|
+
session_ids = list(self._sessions.keys())
|
|
74
|
+
for sid in session_ids:
|
|
75
|
+
await self.destroy_session(sid)
|
|
76
|
+
|
|
77
|
+
def list_sessions(self) -> list[str]:
|
|
78
|
+
"""Return list of active session IDs."""
|
|
79
|
+
return list(self._sessions.keys())
|
|
80
|
+
|
|
81
|
+
async def _cleanup_stale(self) -> None:
|
|
82
|
+
"""Remove sessions that have been inactive too long."""
|
|
83
|
+
now = time.time()
|
|
84
|
+
stale = [
|
|
85
|
+
sid
|
|
86
|
+
for sid, last in self._last_activity.items()
|
|
87
|
+
if now - last > INACTIVITY_TIMEOUT
|
|
88
|
+
]
|
|
89
|
+
for sid in stale:
|
|
90
|
+
logger.info("Cleaning up stale LSP session: %s", sid)
|
|
91
|
+
await self.destroy_session(sid)
|
cpp_debug_mcp/server.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""FastMCP server entry point with lifespan and tool registration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from .gdb.session import GdbSessionManager
|
|
10
|
+
from .lsp.session import LspSessionManager
|
|
11
|
+
from .tools.gdb_tools import register_gdb_tools
|
|
12
|
+
from .tools.lsp_tools import register_lsp_tools
|
|
13
|
+
from .tools.combined_tools import register_combined_tools
|
|
14
|
+
|
|
15
|
+
# Configure logging to stderr (required for STDIO transport)
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.INFO,
|
|
18
|
+
stream=sys.stderr,
|
|
19
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
20
|
+
)
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@asynccontextmanager
|
|
25
|
+
async def lifespan(server):
|
|
26
|
+
"""Manage GDB and LSP session managers lifecycle."""
|
|
27
|
+
gdb_manager = GdbSessionManager()
|
|
28
|
+
lsp_manager = LspSessionManager()
|
|
29
|
+
logger.info("cpp-debug MCP server starting")
|
|
30
|
+
try:
|
|
31
|
+
yield {"gdb": gdb_manager, "lsp": lsp_manager}
|
|
32
|
+
finally:
|
|
33
|
+
logger.info("cpp-debug MCP server shutting down")
|
|
34
|
+
await gdb_manager.destroy_all()
|
|
35
|
+
await lsp_manager.destroy_all()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP(
|
|
39
|
+
name="cpp-debug",
|
|
40
|
+
instructions=(
|
|
41
|
+
"C++ debugging server providing GDB and clangd (LSP) tools. "
|
|
42
|
+
"Use gdb_start_session to begin debugging a compiled executable, "
|
|
43
|
+
"and lsp_start_session for static analysis. "
|
|
44
|
+
"Combined tools like diagnose_crash_site correlate runtime and static info."
|
|
45
|
+
),
|
|
46
|
+
lifespan=lifespan,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Register all tools
|
|
50
|
+
register_gdb_tools(mcp)
|
|
51
|
+
register_lsp_tools(mcp)
|
|
52
|
+
register_combined_tools(mcp)
|
|
File without changes
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Combined GDB + LSP MCP tool definitions."""
|
|
2
|
+
|
|
3
|
+
from fastmcp import Context
|
|
4
|
+
|
|
5
|
+
from ..analysis.correlator import (
|
|
6
|
+
get_crash_report,
|
|
7
|
+
get_variable_info,
|
|
8
|
+
analyze_function_info,
|
|
9
|
+
)
|
|
10
|
+
from . import fmt
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_combined_tools(mcp):
|
|
14
|
+
"""Register combined analysis tools on the FastMCP server instance."""
|
|
15
|
+
|
|
16
|
+
@mcp.tool()
|
|
17
|
+
async def inspect_variable_with_type(
|
|
18
|
+
gdb_session_id: str,
|
|
19
|
+
lsp_session_id: str,
|
|
20
|
+
variable_name: str,
|
|
21
|
+
file_path: str,
|
|
22
|
+
line: int,
|
|
23
|
+
ctx: Context = None,
|
|
24
|
+
) -> str:
|
|
25
|
+
"""Inspect a variable combining GDB runtime value with LSP type information.
|
|
26
|
+
|
|
27
|
+
Gets the variable's current value from GDB and its type/documentation
|
|
28
|
+
from clangd for a complete picture.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
gdb_session_id: Active GDB session (program must be stopped).
|
|
32
|
+
lsp_session_id: Active LSP session for the project.
|
|
33
|
+
variable_name: Name of the variable to inspect.
|
|
34
|
+
file_path: Absolute path to the source file containing the variable.
|
|
35
|
+
line: 1-indexed line number where the variable appears.
|
|
36
|
+
"""
|
|
37
|
+
gdb_mgr = ctx.request_context.lifespan_context["gdb"]
|
|
38
|
+
lsp_mgr = ctx.request_context.lifespan_context["lsp"]
|
|
39
|
+
|
|
40
|
+
gdb_ctrl = gdb_mgr.get_session(gdb_session_id)
|
|
41
|
+
lsp_client = lsp_mgr.get_session(lsp_session_id)
|
|
42
|
+
opened_files = lsp_mgr.get_opened_files(lsp_session_id)
|
|
43
|
+
|
|
44
|
+
result = await get_variable_info(
|
|
45
|
+
gdb_ctrl, lsp_client, opened_files,
|
|
46
|
+
variable_name, file_path, line,
|
|
47
|
+
)
|
|
48
|
+
return fmt.fmt_variable_info(result)
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
async def diagnose_crash_site(
|
|
52
|
+
gdb_session_id: str,
|
|
53
|
+
lsp_session_id: str,
|
|
54
|
+
max_frames: int = 5,
|
|
55
|
+
ctx: Context = None,
|
|
56
|
+
) -> str:
|
|
57
|
+
"""Diagnose a crash by combining GDB backtrace with LSP static analysis.
|
|
58
|
+
|
|
59
|
+
When the program has stopped (e.g., SIGSEGV), this tool gathers:
|
|
60
|
+
- Full backtrace from GDB
|
|
61
|
+
- Local variables at the crash frame
|
|
62
|
+
- Type information at each frame from clangd
|
|
63
|
+
- Static diagnostics (warnings/errors) for relevant source files
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
gdb_session_id: Active GDB session (program must be stopped at crash).
|
|
67
|
+
lsp_session_id: Active LSP session for the project.
|
|
68
|
+
max_frames: Maximum backtrace frames to analyze.
|
|
69
|
+
"""
|
|
70
|
+
gdb_mgr = ctx.request_context.lifespan_context["gdb"]
|
|
71
|
+
lsp_mgr = ctx.request_context.lifespan_context["lsp"]
|
|
72
|
+
|
|
73
|
+
gdb_ctrl = gdb_mgr.get_session(gdb_session_id)
|
|
74
|
+
lsp_client = lsp_mgr.get_session(lsp_session_id)
|
|
75
|
+
opened_files = lsp_mgr.get_opened_files(lsp_session_id)
|
|
76
|
+
|
|
77
|
+
report = await get_crash_report(
|
|
78
|
+
gdb_ctrl, lsp_client, opened_files, max_frames,
|
|
79
|
+
)
|
|
80
|
+
return fmt.fmt_crash_report(report)
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
async def analyze_function(
|
|
84
|
+
gdb_session_id: str,
|
|
85
|
+
lsp_session_id: str,
|
|
86
|
+
function_name: str,
|
|
87
|
+
ctx: Context = None,
|
|
88
|
+
) -> str:
|
|
89
|
+
"""Analyze a function using both GDB and LSP.
|
|
90
|
+
|
|
91
|
+
Sets a temporary breakpoint at the function, gets its signature and
|
|
92
|
+
references from clangd, and retrieves local variables if the program
|
|
93
|
+
is stopped there.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
gdb_session_id: Active GDB session.
|
|
97
|
+
lsp_session_id: Active LSP session for the project.
|
|
98
|
+
function_name: Name of the function to analyze.
|
|
99
|
+
"""
|
|
100
|
+
gdb_mgr = ctx.request_context.lifespan_context["gdb"]
|
|
101
|
+
lsp_mgr = ctx.request_context.lifespan_context["lsp"]
|
|
102
|
+
|
|
103
|
+
gdb_ctrl = gdb_mgr.get_session(gdb_session_id)
|
|
104
|
+
lsp_client = lsp_mgr.get_session(lsp_session_id)
|
|
105
|
+
opened_files = lsp_mgr.get_opened_files(lsp_session_id)
|
|
106
|
+
|
|
107
|
+
result = await analyze_function_info(
|
|
108
|
+
gdb_ctrl, lsp_client, opened_files, function_name,
|
|
109
|
+
)
|
|
110
|
+
return fmt.fmt_function_analysis(result)
|