ida-pro-mcp-xjoker 1.0.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.
Files changed (45) hide show
  1. ida_pro_mcp/__init__.py +0 -0
  2. ida_pro_mcp/__main__.py +6 -0
  3. ida_pro_mcp/ida_mcp/__init__.py +68 -0
  4. ida_pro_mcp/ida_mcp/api_analysis.py +1296 -0
  5. ida_pro_mcp/ida_mcp/api_core.py +337 -0
  6. ida_pro_mcp/ida_mcp/api_debug.py +617 -0
  7. ida_pro_mcp/ida_mcp/api_memory.py +304 -0
  8. ida_pro_mcp/ida_mcp/api_modify.py +406 -0
  9. ida_pro_mcp/ida_mcp/api_python.py +179 -0
  10. ida_pro_mcp/ida_mcp/api_resources.py +295 -0
  11. ida_pro_mcp/ida_mcp/api_stack.py +167 -0
  12. ida_pro_mcp/ida_mcp/api_types.py +480 -0
  13. ida_pro_mcp/ida_mcp/auth.py +166 -0
  14. ida_pro_mcp/ida_mcp/cache.py +232 -0
  15. ida_pro_mcp/ida_mcp/config.py +228 -0
  16. ida_pro_mcp/ida_mcp/framework.py +547 -0
  17. ida_pro_mcp/ida_mcp/http.py +859 -0
  18. ida_pro_mcp/ida_mcp/port_utils.py +104 -0
  19. ida_pro_mcp/ida_mcp/rpc.py +187 -0
  20. ida_pro_mcp/ida_mcp/server_manager.py +339 -0
  21. ida_pro_mcp/ida_mcp/sync.py +233 -0
  22. ida_pro_mcp/ida_mcp/tests/__init__.py +14 -0
  23. ida_pro_mcp/ida_mcp/tests/test_api_analysis.py +336 -0
  24. ida_pro_mcp/ida_mcp/tests/test_api_core.py +237 -0
  25. ida_pro_mcp/ida_mcp/tests/test_api_memory.py +207 -0
  26. ida_pro_mcp/ida_mcp/tests/test_api_modify.py +123 -0
  27. ida_pro_mcp/ida_mcp/tests/test_api_resources.py +199 -0
  28. ida_pro_mcp/ida_mcp/tests/test_api_stack.py +77 -0
  29. ida_pro_mcp/ida_mcp/tests/test_api_types.py +249 -0
  30. ida_pro_mcp/ida_mcp/ui.py +357 -0
  31. ida_pro_mcp/ida_mcp/utils.py +1186 -0
  32. ida_pro_mcp/ida_mcp/zeromcp/__init__.py +5 -0
  33. ida_pro_mcp/ida_mcp/zeromcp/jsonrpc.py +384 -0
  34. ida_pro_mcp/ida_mcp/zeromcp/mcp.py +883 -0
  35. ida_pro_mcp/ida_mcp.py +186 -0
  36. ida_pro_mcp/idalib_server.py +354 -0
  37. ida_pro_mcp/idalib_session_manager.py +259 -0
  38. ida_pro_mcp/server.py +1060 -0
  39. ida_pro_mcp/test.py +170 -0
  40. ida_pro_mcp_xjoker-1.0.1.dist-info/METADATA +405 -0
  41. ida_pro_mcp_xjoker-1.0.1.dist-info/RECORD +45 -0
  42. ida_pro_mcp_xjoker-1.0.1.dist-info/WHEEL +5 -0
  43. ida_pro_mcp_xjoker-1.0.1.dist-info/entry_points.txt +4 -0
  44. ida_pro_mcp_xjoker-1.0.1.dist-info/licenses/LICENSE +21 -0
  45. ida_pro_mcp_xjoker-1.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,547 @@
1
+ """IDA Pro MCP Test Framework
2
+
3
+ This module provides a custom test framework for testing IDA MCP tools.
4
+ Tests can be defined inline or in separate test files using the @test decorator.
5
+
6
+ Usage from IDA console:
7
+ from ida_mcp.tests import run_tests
8
+ run_tests() # Run all tests
9
+ run_tests(category="api_core") # Run specific category
10
+ run_tests(pattern="*meta*") # Run tests matching pattern
11
+
12
+ Usage from command line:
13
+ ida-mcp-test tests/crackme03.elf
14
+ ida-mcp-test tests/crackme03.elf --category api_core
15
+ ida-mcp-test tests/crackme03.elf --pattern "*meta*"
16
+ """
17
+
18
+ import fnmatch
19
+ import time
20
+ import traceback
21
+ from dataclasses import dataclass, field
22
+ from typing import Any, Callable, Literal, Optional
23
+
24
+
25
+ # ============================================================================
26
+ # Test Registry
27
+ # ============================================================================
28
+
29
+
30
+ @dataclass
31
+ class TestInfo:
32
+ """Information about a registered test."""
33
+
34
+ func: Callable
35
+ binary: str # Specific binary this test applies to
36
+ module: str # Auto-extracted category: "api_core", "api_analysis", etc.
37
+ skip: bool = False
38
+
39
+
40
+ # Global test registry: name -> TestInfo
41
+ TESTS: dict[str, TestInfo] = {}
42
+
43
+
44
+ def test(*, binary: str = "", skip: bool = False) -> Callable:
45
+ """Decorator to register a test function.
46
+
47
+ Args:
48
+ binary: Name of the specific binary this test applies to
49
+ skip: If True, test will be skipped
50
+
51
+ Example:
52
+ @test()
53
+ def test_idb_meta():
54
+ meta = idb_meta()
55
+ assert_has_keys(meta, "path", "module")
56
+
57
+ @test(skip=True)
58
+ def test_broken_feature():
59
+ # This test is skipped
60
+ pass
61
+
62
+ @test(binary="crackme03.elf")
63
+ def test_crackme_specific():
64
+ # Only runs for crackme03.elf
65
+ pass
66
+ """
67
+
68
+ def decorator(func: Callable) -> Callable:
69
+ # Extract module category from function's module name
70
+ # Handles both inline tests (api_core) and separate test files (test_api_core)
71
+ # e.g., "ida_pro_mcp.ida_mcp.api_core" -> "api_core"
72
+ # e.g., "ida_pro_mcp.ida_mcp.tests.test_api_core" -> "api_core"
73
+ module_name = func.__module__
74
+ if "." in module_name:
75
+ category = module_name.rsplit(".", 1)[-1]
76
+ else:
77
+ category = module_name
78
+
79
+ # Remove "test_" prefix if present (for separate test files)
80
+ if category.startswith("test_"):
81
+ category = category[5:]
82
+
83
+ # Register the test
84
+ TESTS[func.__name__] = TestInfo(
85
+ func=func,
86
+ binary=binary,
87
+ module=category,
88
+ skip=skip,
89
+ )
90
+ return func
91
+
92
+ return decorator
93
+
94
+
95
+ # ============================================================================
96
+ # Test Results
97
+ # ============================================================================
98
+
99
+
100
+ @dataclass
101
+ class TestResult:
102
+ """Result of a single test execution."""
103
+
104
+ name: str
105
+ category: str
106
+ status: Literal["passed", "failed", "skipped"]
107
+ duration: float = 0.0
108
+ error: Optional[str] = None
109
+ traceback: Optional[str] = None
110
+
111
+
112
+ @dataclass
113
+ class TestResults:
114
+ """Aggregate results of a test run."""
115
+
116
+ passed: int = 0
117
+ failed: int = 0
118
+ skipped: int = 0
119
+ total_time: float = 0.0
120
+ results: list[TestResult] = field(default_factory=list)
121
+
122
+ def add(self, result: TestResult) -> None:
123
+ """Add a test result and update counts."""
124
+ self.results.append(result)
125
+ if result.status == "passed":
126
+ self.passed += 1
127
+ elif result.status == "failed":
128
+ self.failed += 1
129
+ elif result.status == "skipped":
130
+ self.skipped += 1
131
+
132
+
133
+ # ============================================================================
134
+ # Assertion Helpers
135
+ # ============================================================================
136
+
137
+
138
+ def assert_valid_address(addr: str) -> None:
139
+ """Assert addr is a valid hex string starting with 0x."""
140
+ assert isinstance(addr, str), f"Expected string, got {type(addr).__name__}"
141
+ assert addr.startswith("0x") or addr.startswith("-0x"), (
142
+ f"Expected hex address, got {addr!r}"
143
+ )
144
+ # Verify it's a valid hex number
145
+ try:
146
+ int(addr, 16)
147
+ except ValueError:
148
+ raise AssertionError(f"Invalid hex address: {addr!r}")
149
+
150
+
151
+ def assert_has_keys(d: dict, *keys: str) -> None:
152
+ """Assert dict has all specified keys."""
153
+ assert isinstance(d, dict), f"Expected dict, got {type(d).__name__}"
154
+ missing = [k for k in keys if k not in d]
155
+ assert not missing, f"Missing keys: {missing}"
156
+
157
+
158
+ def assert_non_empty(value: Any) -> None:
159
+ """Assert value is not None and not empty."""
160
+ assert value is not None, "Value is None"
161
+ if hasattr(value, "__len__"):
162
+ assert len(value) > 0, f"Value is empty: {value!r}"
163
+
164
+
165
+ def assert_is_list(value: Any, min_length: int = 0) -> None:
166
+ """Assert value is a list with at least min_length items."""
167
+ assert isinstance(value, list), f"Expected list, got {type(value).__name__}"
168
+ assert len(value) >= min_length, (
169
+ f"Expected at least {min_length} items, got {len(value)}"
170
+ )
171
+
172
+
173
+ def assert_all_have_keys(items: list[dict], *keys: str) -> None:
174
+ """Assert all dicts in list have specified keys."""
175
+ assert_is_list(items)
176
+ for i, item in enumerate(items):
177
+ assert isinstance(item, dict), f"Item {i} is not a dict: {type(item).__name__}"
178
+ missing = [k for k in keys if k not in item]
179
+ assert not missing, f"Item {i} missing keys: {missing}"
180
+
181
+
182
+ # ============================================================================
183
+ # Test Configuration
184
+ # ============================================================================
185
+
186
+ # Default sample size for deterministic sampling helpers
187
+ # Can be overridden by test runner via set_sample_size()
188
+ _sample_size: int = 5
189
+
190
+
191
+ def set_sample_size(n: int) -> None:
192
+ """Set the sample size for deterministic sampling helpers.
193
+
194
+ Called by test runner to configure how many items to sample.
195
+ """
196
+ global _sample_size
197
+ _sample_size = max(1, n)
198
+
199
+
200
+ def get_sample_size() -> int:
201
+ """Get the current sample size."""
202
+ return _sample_size
203
+
204
+
205
+ # ============================================================================
206
+ # Test Data Helpers
207
+ # ============================================================================
208
+
209
+
210
+ def get_any_function() -> Optional[str]:
211
+ """Returns address of first function, or None if no functions.
212
+
213
+ Must be called from within IDA context.
214
+ """
215
+ import idautils
216
+
217
+ for ea in idautils.Functions():
218
+ return hex(ea)
219
+ return None
220
+
221
+
222
+ def get_any_string() -> Optional[str]:
223
+ """Returns address of first string, or None if no strings.
224
+
225
+ Must be called from within IDA context.
226
+ """
227
+ import idaapi
228
+
229
+ for i in range(idaapi.get_strlist_qty()):
230
+ si = idaapi.string_info_t()
231
+ if idaapi.get_strlist_item(si, i):
232
+ return hex(si.ea)
233
+ return None
234
+
235
+
236
+ def get_first_segment() -> Optional[tuple[str, str]]:
237
+ """Returns (start_addr, end_addr) of first segment, or None.
238
+
239
+ Must be called from within IDA context.
240
+ """
241
+ import idaapi
242
+ import idautils
243
+
244
+ for seg_ea in idautils.Segments():
245
+ seg = idaapi.getseg(seg_ea)
246
+ if seg:
247
+ return (hex(seg.start_ea), hex(seg.end_ea))
248
+ return None
249
+
250
+
251
+ def _deterministic_sample(items: list, n: int) -> list:
252
+ """Select n items deterministically based on binary name.
253
+
254
+ Uses hash of (binary_name, item_index) for reproducible but varied selection.
255
+ """
256
+ import hashlib
257
+
258
+ if len(items) <= n:
259
+ return items
260
+
261
+ binary_name = get_current_binary_name()
262
+
263
+ # Create (hash, item) pairs for sorting
264
+ def item_hash(idx: int) -> int:
265
+ key = f"{binary_name}:{idx}".encode()
266
+ return int(hashlib.md5(key).hexdigest(), 16)
267
+
268
+ indexed = [(item_hash(i), item) for i, item in enumerate(items)]
269
+ indexed.sort(key=lambda x: x[0])
270
+
271
+ return [item for _, item in indexed[:n]]
272
+
273
+
274
+ def get_n_functions(n: Optional[int] = None) -> list[str]:
275
+ """Get N function addresses, deterministically selected.
276
+
277
+ Selection is based on binary name for reproducibility across runs.
278
+ Returns up to N addresses (fewer if binary has fewer functions).
279
+
280
+ Args:
281
+ n: Number of functions to return. Defaults to global sample_size.
282
+ """
283
+ import idautils
284
+
285
+ if n is None:
286
+ n = _sample_size
287
+
288
+ all_funcs = [hex(ea) for ea in idautils.Functions()]
289
+ return _deterministic_sample(all_funcs, n)
290
+
291
+
292
+ def get_n_strings(n: Optional[int] = None) -> list[str]:
293
+ """Get N string addresses, deterministically selected.
294
+
295
+ Args:
296
+ n: Number of strings to return. Defaults to global sample_size.
297
+ """
298
+ import idaapi
299
+
300
+ if n is None:
301
+ n = _sample_size
302
+
303
+ all_strings = []
304
+ for i in range(idaapi.get_strlist_qty()):
305
+ si = idaapi.string_info_t()
306
+ if idaapi.get_strlist_item(si, i):
307
+ all_strings.append(hex(si.ea))
308
+
309
+ return _deterministic_sample(all_strings, n)
310
+
311
+
312
+ def get_data_address() -> Optional[str]:
313
+ """Get an address in a data segment (not code).
314
+
315
+ Useful for testing error paths when code address is expected.
316
+ """
317
+ import idaapi
318
+ import idautils
319
+
320
+ for seg_ea in idautils.Segments():
321
+ seg = idaapi.getseg(seg_ea)
322
+ if seg and not (seg.perm & idaapi.SEGPERM_EXEC):
323
+ # Return first address in non-executable segment
324
+ return hex(seg.start_ea)
325
+ return None
326
+
327
+
328
+ def get_unmapped_address() -> str:
329
+ """Get an address that is not mapped in the binary.
330
+
331
+ Useful for testing error paths for invalid addresses.
332
+ """
333
+ return "0xDEADBEEFDEADBEEF"
334
+
335
+
336
+ def get_functions_with_calls() -> list[str]:
337
+ """Get functions that contain call instructions (have callees).
338
+
339
+ Useful for testing callees() on functions that actually call others.
340
+ """
341
+ import idaapi
342
+ import idautils
343
+
344
+ result = []
345
+ for func_ea in idautils.Functions():
346
+ func = idaapi.get_func(func_ea)
347
+ if not func:
348
+ continue
349
+
350
+ # Check if function contains any call instructions
351
+ has_call = False
352
+ for head in idautils.Heads(func.start_ea, func.end_ea):
353
+ insn = idaapi.insn_t()
354
+ if idaapi.decode_insn(insn, head) > 0:
355
+ if insn.itype in [idaapi.NN_call, idaapi.NN_callfi, idaapi.NN_callni]:
356
+ has_call = True
357
+ break
358
+
359
+ if has_call:
360
+ result.append(hex(func_ea))
361
+
362
+ return _deterministic_sample(result, _sample_size)
363
+
364
+
365
+ def get_functions_with_callers() -> list[str]:
366
+ """Get functions that are called by other functions (have callers).
367
+
368
+ Useful for testing callers() on functions that are actually called.
369
+ """
370
+ import idaapi
371
+ import idautils
372
+
373
+ result = []
374
+ for func_ea in idautils.Functions():
375
+ # Check if this function has any code references to it
376
+ has_caller = False
377
+ for xref in idautils.XrefsTo(func_ea, 0):
378
+ if xref.iscode:
379
+ caller_func = idaapi.get_func(xref.frm)
380
+ if caller_func and caller_func.start_ea != func_ea:
381
+ has_caller = True
382
+ break
383
+
384
+ if has_caller:
385
+ result.append(hex(func_ea))
386
+
387
+ return _deterministic_sample(result, _sample_size)
388
+
389
+
390
+ # ============================================================================
391
+ # Test Runner
392
+ # ============================================================================
393
+
394
+
395
+ def get_current_binary_name() -> str:
396
+ """Get the name of the currently loaded binary.
397
+
398
+ Returns:
399
+ The filename of the current IDB (e.g., "crackme03.elf")
400
+ """
401
+ import idaapi
402
+
403
+ return idaapi.get_root_filename()
404
+
405
+
406
+ def run_tests(
407
+ pattern: str = "*",
408
+ category: str = "*",
409
+ verbose: bool = True,
410
+ stop_on_failure: bool = False,
411
+ ) -> TestResults:
412
+ """Run registered tests and return results.
413
+
414
+ Args:
415
+ pattern: Glob pattern to filter test names (e.g., "*meta*")
416
+ category: Filter by module category (e.g., "api_core", "api_analysis")
417
+ verbose: Print progress and results
418
+ stop_on_failure: Stop at first failure
419
+
420
+ Returns:
421
+ TestResults with pass/fail counts and individual results
422
+ """
423
+ results = TestResults()
424
+ start_time = time.time()
425
+
426
+ # Get current binary name for filtering binary-specific tests
427
+ current_binary = get_current_binary_name()
428
+
429
+ # Group tests by category
430
+ tests_by_category: dict[str, list[tuple[str, TestInfo]]] = {}
431
+ for name, info in sorted(TESTS.items()):
432
+ # Filter by pattern
433
+ if not fnmatch.fnmatch(name, pattern):
434
+ continue
435
+ # Filter by category
436
+ if category != "*" and info.module != category:
437
+ continue
438
+ # Filter by binary - skip tests for other binaries
439
+ if info.binary and info.binary != current_binary:
440
+ continue
441
+
442
+ if info.module not in tests_by_category:
443
+ tests_by_category[info.module] = []
444
+ tests_by_category[info.module].append((name, info))
445
+
446
+ if not tests_by_category:
447
+ if verbose:
448
+ print(f"No tests found matching pattern={pattern!r}, category={category!r}")
449
+ return results
450
+
451
+ # Print header
452
+ if verbose:
453
+ print("=" * 80)
454
+ print("IDA Pro MCP Test Runner")
455
+ print("=" * 80)
456
+ print()
457
+
458
+ # Run tests by category
459
+ for cat_name in sorted(tests_by_category.keys()):
460
+ tests = tests_by_category[cat_name]
461
+ if verbose:
462
+ print(f"[{cat_name}] Running {len(tests)} tests...")
463
+
464
+ for name, info in tests:
465
+ result = _run_single_test(name, info, verbose)
466
+ results.add(result)
467
+
468
+ if result.status == "failed" and stop_on_failure:
469
+ if verbose:
470
+ print()
471
+ print("Stopping on first failure.")
472
+ break
473
+
474
+ if stop_on_failure and results.failed > 0:
475
+ break
476
+
477
+ if verbose:
478
+ print()
479
+
480
+ results.total_time = time.time() - start_time
481
+
482
+ # Print summary
483
+ if verbose:
484
+ print("=" * 80)
485
+ status_parts = []
486
+ if results.passed:
487
+ status_parts.append(f"{results.passed} passed")
488
+ if results.failed:
489
+ status_parts.append(f"{results.failed} failed")
490
+ if results.skipped:
491
+ status_parts.append(f"{results.skipped} skipped")
492
+ print(f"Results: {', '.join(status_parts)} ({results.total_time:.2f}s)")
493
+ print("=" * 80)
494
+
495
+ return results
496
+
497
+
498
+ def _run_single_test(name: str, info: TestInfo, verbose: bool) -> TestResult:
499
+ """Run a single test and return the result."""
500
+ # Handle skipped tests
501
+ if info.skip:
502
+ if verbose:
503
+ print(f" - {name} (skipped)")
504
+ return TestResult(
505
+ name=name,
506
+ category=info.module,
507
+ status="skipped",
508
+ )
509
+
510
+ # Run the test
511
+ start_time = time.time()
512
+ try:
513
+ info.func()
514
+ duration = time.time() - start_time
515
+
516
+ if verbose:
517
+ print(f" + {name} ({duration:.2f}s)")
518
+
519
+ return TestResult(
520
+ name=name,
521
+ category=info.module,
522
+ status="passed",
523
+ duration=duration,
524
+ )
525
+
526
+ except Exception as e:
527
+ duration = time.time() - start_time
528
+ error_msg = str(e)
529
+ tb = traceback.format_exc()
530
+
531
+ if verbose:
532
+ print(f" x {name} ({duration:.2f}s)")
533
+ print(f" {type(e).__name__}: {error_msg}")
534
+ print()
535
+ # Indent traceback
536
+ for line in tb.strip().split("\n"):
537
+ print(f" {line}")
538
+ print()
539
+
540
+ return TestResult(
541
+ name=name,
542
+ category=info.module,
543
+ status="failed",
544
+ duration=duration,
545
+ error=f"{type(e).__name__}: {error_msg}",
546
+ traceback=tb,
547
+ )