pyghidra-mcp-cli 0.1.0__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,8 @@
1
+ """PyGhidra MCP CLI package.
2
+
3
+ A command-line client for the pyghidra-mcp server, providing an agent-friendly
4
+ interface to interact with Ghidra binary analysis via CLI
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __author__ = "clearbluejar"
@@ -0,0 +1,373 @@
1
+ """MCP Client wrapper for pyghidra-mcp server.
2
+
3
+ This module provides a simplified async client interface to the pyghidra-mcp server
4
+ via HTTP transport only.
5
+
6
+ Usage:
7
+ client = PyGhidraMcpClient(host="localhost", port=8000)
8
+ await client.connect()
9
+ result = await client.list_project_binaries()
10
+ await client.close()
11
+ """
12
+
13
+ import asyncio
14
+ import json
15
+ from typing import Any
16
+
17
+ from mcp import ClientSession
18
+
19
+ from .utils import get_server_start_message
20
+
21
+
22
+ class ClientError(Exception):
23
+ """Custom exception for client errors."""
24
+
25
+ pass
26
+
27
+
28
+ class ServerNotRunningError(ClientError):
29
+ """Raised when the pyghidra-mcp server is not running."""
30
+
31
+ pass
32
+
33
+
34
+ class BinaryNotFoundError(ClientError):
35
+ """Raised when a binary is not found in the project."""
36
+
37
+ pass
38
+
39
+
40
+ class PyGhidraMcpClient:
41
+ """
42
+ Wrapper around MCP ClientSession for pyghidra-mcp server.
43
+
44
+ Handles connection management and provides a simpler interface
45
+ for common operations via HTTP transport.
46
+
47
+ Usage:
48
+ async with PyGhidraMcpClient(host="localhost", port=8000) as client:
49
+ result = await client.list_project_binaries()
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ host: str = "127.0.0.1",
55
+ port: int = 8000,
56
+ ):
57
+ """
58
+ Initialize the MCP client.
59
+
60
+ Args:
61
+ host: Server host
62
+ port: Server port
63
+ """
64
+ self.host = host
65
+ self.port = port
66
+ self._session: ClientSession | None = None
67
+ self._session_cm = None
68
+ self._transport_cm = None
69
+ self._connected = False
70
+
71
+ async def __aenter__(self):
72
+ """Async context manager entry - establishes connection to server."""
73
+ await self._connect_internal()
74
+ return self
75
+
76
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
77
+ """Async context manager exit - closes connection and cleanup resources."""
78
+ await self._close_internal()
79
+ # Return None to propagate exceptions normally
80
+ return None
81
+
82
+ async def _connect_internal(self) -> None:
83
+ """Internal connection logic - establish connection to the pyghidra-mcp server."""
84
+ from mcp.client.session import ClientSession
85
+ from mcp.client.streamable_http import streamablehttp_client
86
+
87
+ url = f"http://{self.host}:{self.port}/mcp"
88
+
89
+ transport_gen = streamablehttp_client(url)
90
+ try:
91
+ read, write, _ = await asyncio.wait_for(transport_gen.__aenter__(), timeout=5.0)
92
+ except asyncio.TimeoutError:
93
+ try:
94
+ await transport_gen.__aexit__(None, None, None)
95
+ except Exception:
96
+ pass
97
+ raise ServerNotRunningError(
98
+ f"Cannot connect to pyghidra-mcp server at {url}\n\n{get_server_start_message()}"
99
+ ) from None
100
+ except (ConnectionError, OSError) as e:
101
+ try:
102
+ await transport_gen.__aexit__(None, None, None)
103
+ except Exception:
104
+ pass
105
+ raise ServerNotRunningError(
106
+ f"Cannot connect to pyghidra-mcp server at {url}\n\n{get_server_start_message()}"
107
+ ) from e
108
+ except Exception as e:
109
+ try:
110
+ await transport_gen.__aexit__(None, None, None)
111
+ except Exception:
112
+ pass
113
+ error_msg = str(e)
114
+ if any(x in error_msg for x in ["ConnectError", "connection", "ConnectionRefused"]):
115
+ raise ServerNotRunningError(
116
+ f"Cannot connect to pyghidra-mcp server at {url}\n\n"
117
+ f"{get_server_start_message()}"
118
+ ) from e
119
+ raise ServerNotRunningError(
120
+ f"Cannot connect to pyghidra-mcp server at {url}: {e}\n\n"
121
+ f"{get_server_start_message()}"
122
+ ) from e
123
+
124
+ self._transport_cm = transport_gen
125
+ self._session_cm = ClientSession(read, write)
126
+ self._session = await self._session_cm.__aenter__()
127
+ await self._session.initialize()
128
+ self._connected = True
129
+
130
+ async def _close_internal(self) -> None:
131
+ """Internal cleanup logic - close the connection and cleanup resources."""
132
+ if self._session_cm and self._connected:
133
+ self._connected = False
134
+ try:
135
+ await self._session_cm.__aexit__(None, None, None)
136
+ except Exception:
137
+ pass
138
+ self._session_cm = None
139
+ self._session = None
140
+
141
+ if self._transport_cm:
142
+ try:
143
+ await self._transport_cm.__aexit__(None, None, None)
144
+ except Exception:
145
+ pass
146
+ self._transport_cm = None
147
+
148
+ def _extract_result(self, result) -> dict[str, Any]:
149
+ """Extract data from MCP result, handling structuredContent and errors."""
150
+ result_dict = result.model_dump()
151
+
152
+ if result_dict.get("isError"):
153
+ content = result_dict.get("content", [])
154
+ if content and len(content) > 0:
155
+ error_text = content[0].get("text", "Unknown error")
156
+ raise ClientError(error_text)
157
+ raise ClientError("Unknown error occurred")
158
+
159
+ if "structuredContent" in result_dict:
160
+ structured = result_dict["structuredContent"]
161
+ if structured is None:
162
+ # Check if there's valid data in content[0].text (for metadata responses)
163
+ content = result_dict.get("content", [])
164
+ if content and len(content) > 0 and content[0].get("text"):
165
+ try:
166
+ return json.loads(content[0]["text"])
167
+ except (json.JSONDecodeError, KeyError):
168
+ pass
169
+ raise BinaryNotFoundError(
170
+ "Binary not found. "
171
+ "Run 'pyghidra-mcp-cli list binaries' to see available binaries."
172
+ )
173
+ return structured
174
+
175
+ if result_dict is None or (isinstance(result_dict, dict) and not result_dict):
176
+ raise BinaryNotFoundError(
177
+ "Binary not found. Run 'pyghidra-mcp-cli list binaries' to see available binaries."
178
+ )
179
+
180
+ return result_dict
181
+
182
+ async def list_project_binaries(self) -> dict[str, Any]:
183
+ """List all binaries in the project."""
184
+ if not self._connected:
185
+ raise ClientError("Not connected")
186
+
187
+ result = await self._session.call_tool("list_project_binaries", {})
188
+ return self._extract_result(result)
189
+
190
+ async def decompile_function(
191
+ self, binary_name: str, function_name_or_address: str
192
+ ) -> dict[str, Any]:
193
+ """Decompile a function."""
194
+ if not self._connected:
195
+ raise ClientError("Not connected")
196
+
197
+ result = await self._session.call_tool(
198
+ "decompile_function",
199
+ {"binary_name": binary_name, "name_or_address": function_name_or_address},
200
+ )
201
+ return self._extract_result(result)
202
+
203
+ async def search_symbols(
204
+ self, binary_name: str, query: str, offset: int = 0, limit: int = 25
205
+ ) -> dict[str, Any]:
206
+ """Search for symbols by name."""
207
+ if not self._connected:
208
+ raise ClientError("Not connected")
209
+
210
+ result = await self._session.call_tool(
211
+ "search_symbols_by_name",
212
+ {
213
+ "binary_name": binary_name,
214
+ "query": query,
215
+ "offset": offset,
216
+ "limit": limit,
217
+ },
218
+ )
219
+ return self._extract_result(result)
220
+
221
+ async def search_code(
222
+ self,
223
+ binary_name: str,
224
+ query: str,
225
+ limit: int = 5,
226
+ offset: int = 0,
227
+ search_mode: str = "semantic",
228
+ include_full_code: bool = True,
229
+ preview_length: int = 500,
230
+ similarity_threshold: float = 0.0,
231
+ ) -> dict[str, Any]:
232
+ """Search code by query."""
233
+ if not self._connected:
234
+ raise ClientError("Not connected")
235
+
236
+ result = await self._session.call_tool(
237
+ "search_code",
238
+ {
239
+ "binary_name": binary_name,
240
+ "query": query,
241
+ "limit": limit,
242
+ "offset": offset,
243
+ "search_mode": search_mode,
244
+ "include_full_code": include_full_code,
245
+ "preview_length": preview_length,
246
+ "similarity_threshold": similarity_threshold,
247
+ },
248
+ )
249
+ return self._extract_result(result)
250
+
251
+ async def search_strings(
252
+ self, binary_name: str, query: str, limit: int = 100
253
+ ) -> dict[str, Any]:
254
+ """Search for strings in the binary."""
255
+ if not self._connected:
256
+ raise ClientError("Not connected")
257
+
258
+ result = await self._session.call_tool(
259
+ "search_strings",
260
+ {"binary_name": binary_name, "query": query, "limit": limit},
261
+ )
262
+ return self._extract_result(result)
263
+
264
+ async def list_imports(
265
+ self, binary_name: str, query: str = ".*", offset: int = 0, limit: int = 25
266
+ ) -> dict[str, Any]:
267
+ """List imports in the binary."""
268
+ if not self._connected:
269
+ raise ClientError("Not connected")
270
+
271
+ result = await self._session.call_tool(
272
+ "list_imports",
273
+ {"binary_name": binary_name, "query": query, "offset": offset, "limit": limit},
274
+ )
275
+ return self._extract_result(result)
276
+
277
+ async def list_exports(
278
+ self, binary_name: str, query: str = ".*", offset: int = 0, limit: int = 25
279
+ ) -> dict[str, Any]:
280
+ """List exports in the binary."""
281
+ if not self._connected:
282
+ raise ClientError("Not connected")
283
+
284
+ result = await self._session.call_tool(
285
+ "list_exports",
286
+ {"binary_name": binary_name, "query": query, "offset": offset, "limit": limit},
287
+ )
288
+ return self._extract_result(result)
289
+
290
+ async def list_cross_references(self, binary_name: str, name_or_address: str) -> dict[str, Any]:
291
+ """List cross-references to a symbol or address."""
292
+ if not self._connected:
293
+ raise ClientError("Not connected")
294
+
295
+ result = await self._session.call_tool(
296
+ "list_cross_references",
297
+ {"binary_name": binary_name, "name_or_address": name_or_address},
298
+ )
299
+ return self._extract_result(result)
300
+
301
+ async def read_bytes(self, binary_name: str, address: str, size: int = 32) -> dict[str, Any]:
302
+ """Read bytes from memory."""
303
+ if not self._connected:
304
+ raise ClientError("Not connected")
305
+
306
+ result = await self._session.call_tool(
307
+ "read_bytes",
308
+ {"binary_name": binary_name, "address": address, "size": size},
309
+ )
310
+ return self._extract_result(result)
311
+
312
+ async def gen_callgraph(
313
+ self,
314
+ binary_name: str,
315
+ function_name: str,
316
+ direction: str = "calling",
317
+ display_type: str = "flow",
318
+ condense_threshold: int = 50,
319
+ top_layers: int = 3,
320
+ bottom_layers: int = 3,
321
+ max_run_time: int = 120,
322
+ ) -> dict[str, Any]:
323
+ """Generate a call graph for a function."""
324
+ if not self._connected:
325
+ raise ClientError("Not connected")
326
+
327
+ result = await self._session.call_tool(
328
+ "gen_callgraph",
329
+ {
330
+ "binary_name": binary_name,
331
+ "function_name": function_name,
332
+ "direction": direction,
333
+ "display_type": display_type,
334
+ "condense_threshold": condense_threshold,
335
+ "top_layers": top_layers,
336
+ "bottom_layers": bottom_layers,
337
+ "max_run_time": max_run_time,
338
+ },
339
+ )
340
+ return self._extract_result(result)
341
+
342
+ async def import_binary(self, binary_path: str) -> dict[str, Any]:
343
+ """Import a binary into the project."""
344
+ if not self._connected:
345
+ raise ClientError("Not connected")
346
+
347
+ result = await self._session.call_tool(
348
+ "import_binary",
349
+ {"binary_path": binary_path},
350
+ )
351
+ return self._extract_result(result)
352
+
353
+ async def delete_binary(self, binary_name: str) -> dict[str, Any]:
354
+ """Delete a binary from the project."""
355
+ if not self._connected:
356
+ raise ClientError("Not connected")
357
+
358
+ result = await self._session.call_tool(
359
+ "delete_project_binary",
360
+ {"binary_name": binary_name},
361
+ )
362
+ return self._extract_result(result)
363
+
364
+ async def list_project_binary_metadata(self, binary_name: str) -> dict[str, Any]:
365
+ """Get metadata for a binary."""
366
+ if not self._connected:
367
+ raise ClientError("Not connected")
368
+
369
+ result = await self._session.call_tool(
370
+ "list_project_binary_metadata",
371
+ {"binary_name": binary_name},
372
+ )
373
+ return self._extract_result(result)
File without changes
@@ -0,0 +1,102 @@
1
+ """Call graph commands for pyghidra-mcp CLI."""
2
+
3
+ import asyncio
4
+
5
+ import click
6
+
7
+ from ..client import PyGhidraMcpClient
8
+ from ..utils import format_output, handle_command_error
9
+
10
+
11
+ def binary_option(func):
12
+ """Common --binary option for commands that target a specific binary."""
13
+ return click.option(
14
+ "-b",
15
+ "--binary",
16
+ "binary_name",
17
+ required=True,
18
+ help="Binary name in the project (use 'list binaries' to see available binaries).",
19
+ )(func)
20
+
21
+
22
+ @click.command()
23
+ @binary_option
24
+ @click.argument("function_name")
25
+ @click.option(
26
+ "-d",
27
+ "--direction",
28
+ type=click.Choice(["calling", "called"], case_sensitive=False),
29
+ default="calling",
30
+ help="Direction of the call graph.",
31
+ )
32
+ @click.option(
33
+ "-t",
34
+ "--type",
35
+ "display_type",
36
+ type=click.Choice(["flow", "flow_ends"], case_sensitive=False),
37
+ default="flow",
38
+ help="Display type of the graph.",
39
+ )
40
+ @click.option(
41
+ "--condense-threshold",
42
+ type=int,
43
+ default=50,
44
+ help="Maximum number of edges before graph condensation (default: 50).",
45
+ )
46
+ @click.option(
47
+ "--top-layers",
48
+ type=int,
49
+ default=3,
50
+ help="Number of top layers to show in condensed graph (default: 3).",
51
+ )
52
+ @click.option(
53
+ "--bottom-layers",
54
+ type=int,
55
+ default=3,
56
+ help="Number of bottom layers to show in condensed graph (default: 3).",
57
+ )
58
+ @click.option(
59
+ "--max-run-time",
60
+ type=int,
61
+ default=120,
62
+ help="Maximum run time in seconds (default: 120).",
63
+ )
64
+ @click.pass_context
65
+ def callgraph(
66
+ ctx: click.Context,
67
+ binary_name: str,
68
+ function_name: str,
69
+ direction: str,
70
+ display_type: str,
71
+ condense_threshold: int,
72
+ top_layers: int,
73
+ bottom_layers: int,
74
+ max_run_time: int,
75
+ ) -> None:
76
+ """Generate a call graph for a function in a binary."""
77
+
78
+ async def run():
79
+ client = PyGhidraMcpClient(
80
+ host=ctx.obj["HOST"],
81
+ port=ctx.obj["PORT"],
82
+ )
83
+
84
+ async with client:
85
+ result = await client.gen_callgraph(
86
+ binary_name,
87
+ function_name,
88
+ direction=direction,
89
+ display_type=display_type,
90
+ condense_threshold=condense_threshold,
91
+ top_layers=top_layers,
92
+ bottom_layers=bottom_layers,
93
+ max_run_time=max_run_time,
94
+ )
95
+ format_output(result, ctx.obj["OUTPUT_FORMAT"], ctx.obj["VERBOSE"])
96
+
97
+ try:
98
+ from ..utils import run_async
99
+
100
+ run_async(run())
101
+ except (asyncio.exceptions.CancelledError, Exception) as e:
102
+ handle_command_error(e, ctx)
@@ -0,0 +1,44 @@
1
+ """Decompile commands for pyghidra-mcp CLI."""
2
+
3
+ import asyncio
4
+
5
+ import click
6
+
7
+ from ..client import PyGhidraMcpClient
8
+ from ..utils import format_output, handle_command_error
9
+
10
+
11
+ def binary_option(func):
12
+ """Common --binary option for commands that target a specific binary."""
13
+ return click.option(
14
+ "-b",
15
+ "--binary",
16
+ "binary_name",
17
+ required=True,
18
+ help="Binary name in the project (use 'list binaries' to see available binaries).",
19
+ )(func)
20
+
21
+
22
+ @click.command()
23
+ @binary_option
24
+ @click.argument("function_name_or_address")
25
+ @click.pass_context
26
+ def decompile(ctx: click.Context, binary_name: str, function_name_or_address: str) -> None:
27
+ """Decompile a function in a binary."""
28
+
29
+ client = PyGhidraMcpClient(
30
+ host=ctx.obj["HOST"],
31
+ port=ctx.obj["PORT"],
32
+ )
33
+
34
+ async def run():
35
+ async with client:
36
+ result = await client.decompile_function(binary_name, function_name_or_address)
37
+ format_output(result, ctx.obj["OUTPUT_FORMAT"], ctx.obj["VERBOSE"])
38
+
39
+ try:
40
+ from ..utils import run_async
41
+
42
+ run_async(run())
43
+ except (asyncio.exceptions.CancelledError, Exception) as e:
44
+ handle_command_error(e, ctx)
@@ -0,0 +1,43 @@
1
+ """Delete binary commands for pyghidra-mcp CLI."""
2
+
3
+ import asyncio
4
+
5
+ import click
6
+
7
+ from ..client import PyGhidraMcpClient
8
+ from ..utils import format_output, handle_command_error
9
+
10
+
11
+ def binary_option(func):
12
+ """Common --binary option for commands that target a specific binary."""
13
+ return click.option(
14
+ "-b",
15
+ "--binary",
16
+ "binary_name",
17
+ required=True,
18
+ help="Binary name in the project (use 'list binaries' to see available binaries).",
19
+ )(func)
20
+
21
+
22
+ @click.command()
23
+ @binary_option
24
+ @click.pass_context
25
+ def delete(ctx: click.Context, binary_name: str) -> None:
26
+ """Delete a binary from the project."""
27
+
28
+ client = PyGhidraMcpClient(
29
+ host=ctx.obj["HOST"],
30
+ port=ctx.obj["PORT"],
31
+ )
32
+
33
+ async def run():
34
+ async with client:
35
+ result = await client.delete_binary(binary_name)
36
+ format_output(result, ctx.obj["OUTPUT_FORMAT"], ctx.obj["VERBOSE"])
37
+
38
+ try:
39
+ from ..utils import run_async
40
+
41
+ run_async(run())
42
+ except (asyncio.exceptions.CancelledError, Exception) as e:
43
+ handle_command_error(e, ctx)
@@ -0,0 +1,32 @@
1
+ """Import command for pyghidra-mcp CLI."""
2
+
3
+ import asyncio
4
+
5
+ import click
6
+
7
+ from ..client import PyGhidraMcpClient
8
+ from ..utils import format_output, handle_command_error
9
+
10
+
11
+ @click.command()
12
+ @click.argument("binary_path")
13
+ @click.pass_context
14
+ def import_cmd(ctx: click.Context, binary_path: str) -> None:
15
+ """Import a binary into the project."""
16
+
17
+ client = PyGhidraMcpClient(
18
+ host=ctx.obj["HOST"],
19
+ port=ctx.obj["PORT"],
20
+ )
21
+
22
+ async def run():
23
+ async with client:
24
+ result = await client.import_binary(binary_path)
25
+ format_output(result, ctx.obj["OUTPUT_FORMAT"], ctx.obj["VERBOSE"])
26
+
27
+ try:
28
+ from ..utils import run_async
29
+
30
+ run_async(run())
31
+ except (asyncio.exceptions.CancelledError, Exception) as e:
32
+ handle_command_error(e, ctx)