superghidra-mcp-cli 0.5.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.5.0"
8
+ __author__ = "yuatemycat"
@@ -0,0 +1,596 @@
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 streamable_http_client
86
+
87
+ url = f"http://{self.host}:{self.port}/mcp"
88
+
89
+ transport_gen = streamable_http_client(url)
90
+ try:
91
+ streams = await asyncio.wait_for(transport_gen.__aenter__(), timeout=5.0)
92
+ read, write = streams
93
+ except asyncio.TimeoutError:
94
+ try:
95
+ await transport_gen.__aexit__(None, None, None)
96
+ except Exception:
97
+ pass
98
+ raise ServerNotRunningError(
99
+ f"Cannot connect to pyghidra-mcp server at {url}\n\n{get_server_start_message()}"
100
+ ) from None
101
+ except (ConnectionError, OSError) as e:
102
+ try:
103
+ await transport_gen.__aexit__(None, None, None)
104
+ except Exception:
105
+ pass
106
+ raise ServerNotRunningError(
107
+ f"Cannot connect to pyghidra-mcp server at {url}\n\n{get_server_start_message()}"
108
+ ) from e
109
+ except Exception as e:
110
+ try:
111
+ await transport_gen.__aexit__(None, None, None)
112
+ except Exception:
113
+ pass
114
+ error_msg = str(e)
115
+ if any(x in error_msg for x in ["ConnectError", "connection", "ConnectionRefused"]):
116
+ raise ServerNotRunningError(
117
+ f"Cannot connect to pyghidra-mcp server at {url}\n\n"
118
+ f"{get_server_start_message()}"
119
+ ) from e
120
+ raise ServerNotRunningError(
121
+ f"Cannot connect to pyghidra-mcp server at {url}: {e}\n\n"
122
+ f"{get_server_start_message()}"
123
+ ) from e
124
+
125
+ self._transport_cm = transport_gen
126
+ self._session_cm = ClientSession(read, write)
127
+ self._session = await self._session_cm.__aenter__()
128
+ await self._session.initialize()
129
+ self._connected = True
130
+
131
+ async def _close_internal(self) -> None:
132
+ """Internal cleanup logic - close the connection and cleanup resources."""
133
+ if self._session_cm and self._connected:
134
+ self._connected = False
135
+ try:
136
+ await self._session_cm.__aexit__(None, None, None)
137
+ except Exception:
138
+ pass
139
+ self._session_cm = None
140
+ self._session = None
141
+
142
+ if self._transport_cm:
143
+ try:
144
+ await self._transport_cm.__aexit__(None, None, None)
145
+ except Exception:
146
+ pass
147
+ self._transport_cm = None
148
+
149
+ def _extract_result(self, result) -> dict[str, Any]:
150
+ """Extract data from MCP result, handling structuredContent and errors."""
151
+ result_dict = result.model_dump(by_alias=True)
152
+
153
+ if result_dict.get("isError"):
154
+ content = result_dict.get("content", [])
155
+ if content and len(content) > 0:
156
+ error_text = content[0].get("text", "Unknown error")
157
+ raise ClientError(error_text)
158
+ raise ClientError("Unknown error occurred")
159
+
160
+ if "structuredContent" in result_dict:
161
+ structured = result_dict["structuredContent"]
162
+ if structured is None:
163
+ # Check if there's valid data in content[0].text (for metadata responses)
164
+ content = result_dict.get("content", [])
165
+ if content and len(content) > 0 and content[0].get("text"):
166
+ try:
167
+ return json.loads(content[0]["text"])
168
+ except (json.JSONDecodeError, KeyError):
169
+ pass
170
+ raise BinaryNotFoundError(
171
+ "Binary not found. "
172
+ "Run 'pyghidra-mcp-cli list binaries' to see available binaries."
173
+ )
174
+ return structured
175
+
176
+ if result_dict is None or (isinstance(result_dict, dict) and not result_dict):
177
+ raise BinaryNotFoundError(
178
+ "Binary not found. Run 'pyghidra-mcp-cli list binaries' to see available binaries."
179
+ )
180
+
181
+ return result_dict
182
+
183
+ async def list_project_binaries(self) -> dict[str, Any]:
184
+ """List all binaries in the project."""
185
+ if not self._connected:
186
+ raise ClientError("Not connected")
187
+
188
+ result = await self._session.call_tool("list_project_binaries", {})
189
+ return self._extract_result(result)
190
+
191
+ async def list_open_programs(self) -> dict[str, Any]:
192
+ """List programs open in the Ghidra GUI."""
193
+ if not self._connected:
194
+ raise ClientError("Not connected")
195
+
196
+ result = await self._session.call_tool("list_open_programs", {})
197
+ return self._extract_result(result)
198
+
199
+ async def open_program_in_gui(
200
+ self, binary_name: str, new_window: bool = True
201
+ ) -> dict[str, Any]:
202
+ """Open a binary in the Ghidra GUI."""
203
+ if not self._connected:
204
+ raise ClientError("Not connected")
205
+
206
+ result = await self._session.call_tool(
207
+ "open_program_in_gui",
208
+ {"binary_name": binary_name, "new_window": new_window},
209
+ )
210
+ return self._extract_result(result)
211
+
212
+ async def set_current_program(self, binary_name: str) -> dict[str, Any]:
213
+ """Set the current Ghidra GUI program."""
214
+ if not self._connected:
215
+ raise ClientError("Not connected")
216
+
217
+ result = await self._session.call_tool(
218
+ "set_current_program",
219
+ {"binary_name": binary_name},
220
+ )
221
+ return self._extract_result(result)
222
+
223
+ async def goto(self, binary_name: str, target: str, target_type: str) -> dict[str, Any]:
224
+ """Navigate the Ghidra GUI."""
225
+ if not self._connected:
226
+ raise ClientError("Not connected")
227
+
228
+ result = await self._session.call_tool(
229
+ "goto",
230
+ {"binary_name": binary_name, "target": target, "target_type": target_type},
231
+ )
232
+ return self._extract_result(result)
233
+
234
+ async def decompile_function(
235
+ self,
236
+ binary_name: str,
237
+ function_name_or_address: str,
238
+ include_callees: bool = False,
239
+ include_strings: bool = False,
240
+ include_xrefs: bool = False,
241
+ ) -> dict[str, Any]:
242
+ """Decompile a function."""
243
+ if not self._connected:
244
+ raise ClientError("Not connected")
245
+
246
+ args: dict[str, Any] = {
247
+ "binary_name": binary_name,
248
+ "name_or_address": function_name_or_address,
249
+ }
250
+ if include_callees:
251
+ args["include_callees"] = True
252
+ if include_strings:
253
+ args["include_strings"] = True
254
+ if include_xrefs:
255
+ args["include_xrefs"] = True
256
+
257
+ result = await self._session.call_tool("decompile_function", args)
258
+ extracted = self._extract_result(result)
259
+ # Server returns list[DecompiledFunction]; unwrap single-item batch
260
+ if "result" in extracted and isinstance(extracted["result"], list):
261
+ return extracted["result"][0]
262
+ return extracted
263
+
264
+ async def search_symbols(
265
+ self,
266
+ binary_name: str,
267
+ query: str,
268
+ functions_only: bool = False,
269
+ offset: int = 0,
270
+ limit: int = 25,
271
+ ) -> dict[str, Any]:
272
+ """Search for symbols by name."""
273
+ if not self._connected:
274
+ raise ClientError("Not connected")
275
+
276
+ args: dict[str, Any] = {
277
+ "binary_name": binary_name,
278
+ "query": query,
279
+ "offset": offset,
280
+ "limit": limit,
281
+ }
282
+ if functions_only:
283
+ args["functions_only"] = True
284
+
285
+ result = await self._session.call_tool("search_symbols_by_name", args)
286
+ return self._extract_result(result)
287
+
288
+ async def search_code(
289
+ self,
290
+ binary_name: str,
291
+ query: str,
292
+ limit: int = 5,
293
+ offset: int = 0,
294
+ search_mode: str = "semantic",
295
+ include_full_code: bool = True,
296
+ preview_length: int = 500,
297
+ similarity_threshold: float = 0.0,
298
+ ) -> dict[str, Any]:
299
+ """Search code by query."""
300
+ if not self._connected:
301
+ raise ClientError("Not connected")
302
+
303
+ result = await self._session.call_tool(
304
+ "search_code",
305
+ {
306
+ "binary_name": binary_name,
307
+ "query": query,
308
+ "limit": limit,
309
+ "offset": offset,
310
+ "search_mode": search_mode,
311
+ "include_full_code": include_full_code,
312
+ "preview_length": preview_length,
313
+ "similarity_threshold": similarity_threshold,
314
+ },
315
+ )
316
+ return self._extract_result(result)
317
+
318
+ async def search_strings(
319
+ self, binary_name: str, query: str, limit: int = 100
320
+ ) -> dict[str, Any]:
321
+ """Search for strings in the binary."""
322
+ if not self._connected:
323
+ raise ClientError("Not connected")
324
+
325
+ result = await self._session.call_tool(
326
+ "search_strings",
327
+ {"binary_name": binary_name, "query": query, "limit": limit},
328
+ )
329
+ return self._extract_result(result)
330
+
331
+ async def list_imports(
332
+ self, binary_name: str, query: str = ".*", offset: int = 0, limit: int = 25
333
+ ) -> dict[str, Any]:
334
+ """List imports in the binary."""
335
+ if not self._connected:
336
+ raise ClientError("Not connected")
337
+
338
+ result = await self._session.call_tool(
339
+ "list_imports",
340
+ {"binary_name": binary_name, "query": query, "offset": offset, "limit": limit},
341
+ )
342
+ return self._extract_result(result)
343
+
344
+ async def list_exports(
345
+ self, binary_name: str, query: str = ".*", offset: int = 0, limit: int = 25
346
+ ) -> dict[str, Any]:
347
+ """List exports in the binary."""
348
+ if not self._connected:
349
+ raise ClientError("Not connected")
350
+
351
+ result = await self._session.call_tool(
352
+ "list_exports",
353
+ {"binary_name": binary_name, "query": query, "offset": offset, "limit": limit},
354
+ )
355
+ return self._extract_result(result)
356
+
357
+ async def list_xrefs(self, binary_name: str, name_or_address: str) -> dict[str, Any]:
358
+ """List cross-references to a symbol or address."""
359
+ if not self._connected:
360
+ raise ClientError("Not connected")
361
+
362
+ result = await self._session.call_tool(
363
+ "list_xrefs",
364
+ {"binary_name": binary_name, "name_or_address": name_or_address},
365
+ )
366
+ extracted = self._extract_result(result)
367
+ # Server returns list[CrossReferenceInfos]; unwrap single-item batch
368
+ if "result" in extracted and isinstance(extracted["result"], list):
369
+ return extracted["result"][0]
370
+ return extracted
371
+
372
+ async def read_bytes(self, binary_name: str, address: str, size: int = 32) -> dict[str, Any]:
373
+ """Read bytes from memory."""
374
+ if not self._connected:
375
+ raise ClientError("Not connected")
376
+
377
+ result = await self._session.call_tool(
378
+ "read_bytes",
379
+ {"binary_name": binary_name, "address": address, "size": size},
380
+ )
381
+ return self._extract_result(result)
382
+
383
+ async def gen_callgraph(
384
+ self,
385
+ binary_name: str,
386
+ function_name: str,
387
+ direction: str = "calling",
388
+ display_type: str = "flow",
389
+ condense_threshold: int = 50,
390
+ top_layers: int = 3,
391
+ bottom_layers: int = 3,
392
+ max_run_time: int = 120,
393
+ ) -> dict[str, Any]:
394
+ """Generate a call graph for a function."""
395
+ if not self._connected:
396
+ raise ClientError("Not connected")
397
+
398
+ result = await self._session.call_tool(
399
+ "gen_callgraph",
400
+ {
401
+ "binary_name": binary_name,
402
+ "function_name": function_name,
403
+ "direction": direction,
404
+ "display_type": display_type,
405
+ "condense_threshold": condense_threshold,
406
+ "top_layers": top_layers,
407
+ "bottom_layers": bottom_layers,
408
+ "max_run_time": max_run_time,
409
+ },
410
+ )
411
+ return self._extract_result(result)
412
+
413
+ async def import_binary(self, binary_path: str) -> dict[str, Any]:
414
+ """Import a binary into the project."""
415
+ if not self._connected:
416
+ raise ClientError("Not connected")
417
+
418
+ result = await self._session.call_tool(
419
+ "import_binary",
420
+ {"binary_path": binary_path},
421
+ )
422
+ return self._extract_result(result)
423
+
424
+ async def save(self) -> dict[str, Any]:
425
+ """Save all programs."""
426
+ if not self._connected:
427
+ raise ClientError("Not connected")
428
+
429
+ result = await self._session.call_tool(
430
+ "save",
431
+ )
432
+ return self._extract_result(result)
433
+
434
+ async def delete_binary(self, binary_name: str) -> dict[str, Any]:
435
+ """Delete a binary from the project."""
436
+ if not self._connected:
437
+ raise ClientError("Not connected")
438
+
439
+ result = await self._session.call_tool(
440
+ "delete_project_binary",
441
+ {"binary_name": binary_name},
442
+ )
443
+ return self._extract_result(result)
444
+
445
+ async def list_forks(self) -> dict[str, Any]:
446
+ """List forks of the shared project and this session's role."""
447
+ if not self._connected:
448
+ raise ClientError("Not connected")
449
+
450
+ result = await self._session.call_tool("list_forks", {})
451
+ return self._extract_result(result)
452
+
453
+ async def merge_fork(
454
+ self, fork_id: str, dry_run: bool = False, on_conflict: str = "skip", auto_discard: bool = False
455
+ ) -> dict[str, Any]:
456
+ """Replay a fork's journaled edits into the primary project."""
457
+ if not self._connected:
458
+ raise ClientError("Not connected")
459
+
460
+ result = await self._session.call_tool(
461
+ "merge_fork",
462
+ {"fork_id": fork_id, "dry_run": dry_run, "on_conflict": on_conflict, "auto_discard": auto_discard},
463
+ )
464
+ return self._extract_result(result)
465
+
466
+ async def prune_forks(
467
+ self, dry_run: bool = False, max_age_hours: float = 168.0, include_unmerged: bool = False
468
+ ) -> dict[str, Any]:
469
+ """Prune dead and merged forks to reclaim disk storage."""
470
+ if not self._connected:
471
+ raise ClientError("Not connected")
472
+
473
+ result = await self._session.call_tool(
474
+ "prune_forks",
475
+ {"dry_run": dry_run, "max_age_hours": max_age_hours, "include_unmerged": include_unmerged},
476
+ )
477
+ return self._extract_result(result)
478
+
479
+ async def discard_fork(self, fork_id: str) -> dict[str, Any]:
480
+ """Delete a stopped fork and its journal."""
481
+ if not self._connected:
482
+ raise ClientError("Not connected")
483
+
484
+ result = await self._session.call_tool("discard_fork", {"fork_id": fork_id})
485
+ return self._extract_result(result)
486
+
487
+ async def list_project_binary_metadata(self, binary_name: str) -> dict[str, Any]:
488
+ """Get metadata for a binary."""
489
+ if not self._connected:
490
+ raise ClientError("Not connected")
491
+
492
+ result = await self._session.call_tool(
493
+ "list_project_binary_metadata",
494
+ {"binary_name": binary_name},
495
+ )
496
+ return self._extract_result(result)
497
+
498
+ async def rename_function(
499
+ self, binary_name: str, function_name_or_address: str, new_name: str
500
+ ) -> dict[str, Any]:
501
+ """Rename a function."""
502
+ if not self._connected:
503
+ raise ClientError("Not connected")
504
+
505
+ result = await self._session.call_tool(
506
+ "rename_function",
507
+ {
508
+ "binary_name": binary_name,
509
+ "name_or_address": function_name_or_address,
510
+ "new_name": new_name,
511
+ },
512
+ )
513
+ return self._extract_result(result)
514
+
515
+ async def rename_variable(
516
+ self,
517
+ binary_name: str,
518
+ function_name_or_address: str,
519
+ variable_name: str,
520
+ new_name: str,
521
+ ) -> dict[str, Any]:
522
+ """Rename a parameter or local variable."""
523
+ if not self._connected:
524
+ raise ClientError("Not connected")
525
+
526
+ result = await self._session.call_tool(
527
+ "rename_variable",
528
+ {
529
+ "binary_name": binary_name,
530
+ "function_name_or_address": function_name_or_address,
531
+ "variable_name": variable_name,
532
+ "new_name": new_name,
533
+ },
534
+ )
535
+ return self._extract_result(result)
536
+
537
+ async def set_variable_type(
538
+ self,
539
+ binary_name: str,
540
+ function_name_or_address: str,
541
+ variable_name: str,
542
+ type_name: str,
543
+ ) -> dict[str, Any]:
544
+ """Set a parameter or local variable type."""
545
+ if not self._connected:
546
+ raise ClientError("Not connected")
547
+
548
+ result = await self._session.call_tool(
549
+ "set_variable_type",
550
+ {
551
+ "binary_name": binary_name,
552
+ "function_name_or_address": function_name_or_address,
553
+ "variable_name": variable_name,
554
+ "type_name": type_name,
555
+ },
556
+ )
557
+ return self._extract_result(result)
558
+
559
+ async def set_function_prototype(
560
+ self, binary_name: str, function_name_or_address: str, prototype: str
561
+ ) -> dict[str, Any]:
562
+ """Set a function prototype."""
563
+ if not self._connected:
564
+ raise ClientError("Not connected")
565
+
566
+ result = await self._session.call_tool(
567
+ "set_function_prototype",
568
+ {
569
+ "binary_name": binary_name,
570
+ "function_name_or_address": function_name_or_address,
571
+ "prototype": prototype,
572
+ },
573
+ )
574
+ return self._extract_result(result)
575
+
576
+ async def set_comment(
577
+ self,
578
+ binary_name: str,
579
+ target: str,
580
+ comment: str,
581
+ comment_type: str = "decompiler",
582
+ ) -> dict[str, Any]:
583
+ """Set a decompiler or listing comment."""
584
+ if not self._connected:
585
+ raise ClientError("Not connected")
586
+
587
+ result = await self._session.call_tool(
588
+ "set_comment",
589
+ {
590
+ "binary_name": binary_name,
591
+ "target": target,
592
+ "comment": comment,
593
+ "comment_type": comment_type,
594
+ },
595
+ )
596
+ return self._extract_result(result)
File without changes