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.
@@ -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)
@@ -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)