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,233 @@
1
+ import logging
2
+ import queue
3
+ import functools
4
+ import os
5
+ import sys
6
+ import time
7
+ import idaapi
8
+ import idc
9
+ from .rpc import McpToolError
10
+ from .zeromcp.jsonrpc import get_current_cancel_event, RequestCancelledError
11
+
12
+ # ============================================================================
13
+ # IDA Synchronization & Error Handling
14
+ # ============================================================================
15
+
16
+ ida_major, ida_minor = map(int, idaapi.get_kernel_version().split("."))
17
+
18
+
19
+ class IDAError(McpToolError):
20
+ def __init__(self, message: str):
21
+ super().__init__(message)
22
+
23
+ @property
24
+ def message(self) -> str:
25
+ return self.args[0]
26
+
27
+
28
+ class IDASyncError(Exception):
29
+ pass
30
+
31
+
32
+ class CancelledError(RequestCancelledError):
33
+ """Raised when a request is cancelled via notifications/cancelled."""
34
+
35
+ pass
36
+
37
+
38
+ logger = logging.getLogger(__name__)
39
+ _TOOL_TIMEOUT_ENV = "IDA_MCP_TOOL_TIMEOUT_SEC"
40
+ _DEFAULT_TOOL_TIMEOUT_SEC = 15.0
41
+
42
+
43
+ def _get_tool_timeout_seconds() -> float:
44
+ value = os.getenv(_TOOL_TIMEOUT_ENV, "").strip()
45
+ if value == "":
46
+ return _DEFAULT_TOOL_TIMEOUT_SEC
47
+ try:
48
+ return float(value)
49
+ except ValueError:
50
+ return _DEFAULT_TOOL_TIMEOUT_SEC
51
+
52
+
53
+ # ============================================================================
54
+ # Queue Object Pool - Reduces GC pressure and allocation overhead
55
+ # ============================================================================
56
+
57
+
58
+ class QueuePool:
59
+ """Object pool for Queue instances to reduce allocation overhead.
60
+
61
+ Reuses Queue objects instead of creating new ones for each IDA sync call.
62
+ Thread-safe implementation with automatic cleanup.
63
+ """
64
+
65
+ def __init__(self, max_size: int = 50):
66
+ self._pool: queue.Queue = queue.Queue(maxsize=max_size)
67
+ self._max_size = max_size
68
+
69
+ def acquire(self) -> queue.Queue:
70
+ """Get a Queue from the pool or create a new one."""
71
+ try:
72
+ return self._pool.get_nowait()
73
+ except queue.Empty:
74
+ return queue.Queue()
75
+
76
+ def release(self, q: queue.Queue):
77
+ """Return a Queue to the pool after clearing it."""
78
+ # Clear the queue before returning to pool
79
+ while not q.empty():
80
+ try:
81
+ q.get_nowait()
82
+ except queue.Empty:
83
+ break
84
+ # Try to return to pool
85
+ try:
86
+ self._pool.put_nowait(q)
87
+ except queue.Full:
88
+ # Pool full, let GC handle it
89
+ pass
90
+
91
+
92
+ _queue_pool = QueuePool()
93
+
94
+
95
+ call_stack = queue.LifoQueue()
96
+
97
+
98
+ def _sync_wrapper(ff):
99
+ """Call a function ff with a specific IDA safety_mode."""
100
+
101
+ res_container = _queue_pool.acquire()
102
+
103
+ def runned():
104
+ if not call_stack.empty():
105
+ last_func_name = call_stack.get()
106
+ error_str = f"Call stack is not empty while calling the function {ff.__name__} from {last_func_name}"
107
+ raise IDASyncError(error_str)
108
+
109
+ call_stack.put((ff.__name__))
110
+ try:
111
+ res_container.put(ff())
112
+ except Exception as x:
113
+ res_container.put(x)
114
+ finally:
115
+ call_stack.get()
116
+
117
+ idaapi.execute_sync(runned, idaapi.MFF_WRITE)
118
+ res = res_container.get()
119
+ _queue_pool.release(res_container)
120
+ if isinstance(res, Exception):
121
+ raise res
122
+ return res
123
+
124
+
125
+ def _normalize_timeout(value: object) -> float | None:
126
+ if value is None:
127
+ return None
128
+ try:
129
+ return float(value)
130
+ except (TypeError, ValueError):
131
+ return None
132
+
133
+
134
+ def sync_wrapper(ff, timeout_override: float | None = None):
135
+ """Wrapper to enable batch mode during IDA synchronization."""
136
+ # Capture cancel event from thread-local before execute_sync
137
+ cancel_event = get_current_cancel_event()
138
+
139
+ def _run_with_batch(inner_ff):
140
+ def _wrapped():
141
+ old_batch = idc.batch(1)
142
+ try:
143
+ return inner_ff()
144
+ finally:
145
+ idc.batch(old_batch)
146
+
147
+ _wrapped.__name__ = inner_ff.__name__
148
+ return _wrapped
149
+
150
+ timeout = timeout_override
151
+ if timeout is None:
152
+ timeout = _get_tool_timeout_seconds()
153
+ if timeout > 0 or cancel_event is not None:
154
+
155
+ def timed_ff():
156
+ # Calculate deadline when execution starts on IDA main thread,
157
+ # not when the request was queued (avoids stale deadlines)
158
+ deadline = time.monotonic() + timeout if timeout > 0 else None
159
+
160
+ def profilefunc(frame, event, arg):
161
+ # Check cancellation first (higher priority)
162
+ if cancel_event is not None and cancel_event.is_set():
163
+ raise CancelledError("Request was cancelled")
164
+ if deadline is not None and time.monotonic() >= deadline:
165
+ raise IDASyncError(f"Tool timed out after {timeout:.2f}s")
166
+
167
+ old_profile = sys.getprofile()
168
+ sys.setprofile(profilefunc)
169
+ try:
170
+ return ff()
171
+ finally:
172
+ sys.setprofile(old_profile)
173
+
174
+ timed_ff.__name__ = ff.__name__
175
+ return _sync_wrapper(_run_with_batch(timed_ff))
176
+ return _sync_wrapper(_run_with_batch(ff))
177
+
178
+
179
+ def idasync(f):
180
+ """Run the function on the IDA main thread in write mode.
181
+
182
+ This is the unified decorator for all IDA synchronization.
183
+ Previously there were separate @idaread and @idawrite decorators,
184
+ but since read-only operations in IDA might actually require write
185
+ access (e.g., decompilation), we now use a single decorator.
186
+ """
187
+
188
+ @functools.wraps(f)
189
+ def wrapper(*args, **kwargs):
190
+ ff = functools.partial(f, *args, **kwargs)
191
+ ff.__name__ = f.__name__
192
+ timeout_override = _normalize_timeout(
193
+ getattr(f, "__ida_mcp_timeout_sec__", None)
194
+ )
195
+ return sync_wrapper(ff, timeout_override)
196
+
197
+ return wrapper
198
+
199
+
200
+ def tool_timeout(seconds: float):
201
+ """Decorator to override per-tool timeout (seconds).
202
+
203
+ IMPORTANT: Must be applied BEFORE @idasync (i.e., listed AFTER it)
204
+ so the attribute exists when it captures the function in closure.
205
+
206
+ Correct order:
207
+ @tool
208
+ @idasync
209
+ @tool_timeout(90.0) # innermost
210
+ def my_func(...):
211
+ """
212
+
213
+ def decorator(func):
214
+ setattr(func, "__ida_mcp_timeout_sec__", seconds)
215
+ return func
216
+
217
+ return decorator
218
+
219
+
220
+ def is_window_active():
221
+ """Returns whether IDA is currently active."""
222
+ # Source: https://github.com/OALabs/hexcopy-ida/blob/8b0b2a3021d7dc9010c01821b65a80c47d491b61/hexcopy.py#L30
223
+ using_pyside6 = (ida_major > 9) or (ida_major == 9 and ida_minor >= 2)
224
+
225
+ if using_pyside6:
226
+ from PySide6 import QtWidgets
227
+ else:
228
+ from PyQt5 import QtWidgets
229
+
230
+ app = QtWidgets.QApplication.instance()
231
+ if app is None:
232
+ return False
233
+ return app.activeWindow() is not None
@@ -0,0 +1,14 @@
1
+ """IDA Pro MCP Test Package.
2
+
3
+ This package contains test modules for each API module.
4
+ Tests are registered via the @test decorator from the framework module.
5
+ """
6
+
7
+ # Import all test modules to register tests when the package is imported
8
+ from . import test_api_core
9
+ from . import test_api_analysis
10
+ from . import test_api_memory
11
+ from . import test_api_modify
12
+ from . import test_api_types
13
+ from . import test_api_stack
14
+ from . import test_api_resources
@@ -0,0 +1,336 @@
1
+ """Tests for api_analysis API functions."""
2
+
3
+ # Import test framework from parent
4
+ from ..framework import (
5
+ test,
6
+ assert_has_keys,
7
+ assert_is_list,
8
+ get_any_function,
9
+ get_n_functions,
10
+ get_data_address,
11
+ get_unmapped_address,
12
+ get_functions_with_calls,
13
+ get_functions_with_callers,
14
+ )
15
+
16
+ # Import functions under test
17
+ from ..api_analysis import (
18
+ decompile,
19
+ disasm,
20
+ xrefs_to,
21
+ xrefs_to_field,
22
+ callees,
23
+ find_bytes,
24
+ basic_blocks,
25
+ find,
26
+ export_funcs,
27
+ callgraph,
28
+ )
29
+
30
+ # Import sync module for IDAError
31
+
32
+
33
+ # ============================================================================
34
+ # Tests for decompile
35
+ # ============================================================================
36
+
37
+
38
+ @test()
39
+ def test_decompile_valid_function():
40
+ """decompile returns code for a valid function"""
41
+ fn_addr = get_any_function()
42
+ if not fn_addr:
43
+ return
44
+
45
+ result = decompile(fn_addr)
46
+ assert_is_list(result, min_length=1)
47
+ # Should have code or error
48
+ r = result[0]
49
+ assert_has_keys(r, "addr")
50
+ # Either has code or has an error
51
+ assert r.get("code") is not None or r.get("error") is not None
52
+
53
+
54
+ @test()
55
+ def test_decompile_invalid_address():
56
+ """decompile handles invalid address gracefully"""
57
+ result = decompile(get_unmapped_address())
58
+ assert_is_list(result, min_length=1)
59
+ # Should have an error
60
+ assert result[0].get("error") is not None or result[0].get("code") is None
61
+
62
+
63
+ @test()
64
+ def test_decompile_batch():
65
+ """decompile can handle multiple addresses"""
66
+ addrs = get_n_functions(3)
67
+ if len(addrs) < 2:
68
+ return
69
+
70
+ result = decompile(addrs)
71
+ assert len(result) == len(addrs)
72
+
73
+
74
+ # ============================================================================
75
+ # Tests for disasm
76
+ # ============================================================================
77
+
78
+
79
+ @test()
80
+ def test_disasm_valid_function():
81
+ """disasm returns assembly for a valid function"""
82
+ fn_addr = get_any_function()
83
+ if not fn_addr:
84
+ return
85
+
86
+ result = disasm(fn_addr)
87
+ assert_is_list(result, min_length=1)
88
+ r = result[0]
89
+ assert_has_keys(r, "addr")
90
+ # Should have asm output or error
91
+ assert r.get("asm") is not None or r.get("error") is not None
92
+
93
+
94
+ @test()
95
+ def test_disasm_pagination():
96
+ """disasm respects count parameter"""
97
+ fn_addr = get_any_function()
98
+ if not fn_addr:
99
+ return
100
+
101
+ result = disasm(fn_addr, count=10)
102
+ assert_is_list(result, min_length=1)
103
+
104
+
105
+ @test()
106
+ def test_disasm_unmapped_address():
107
+ """disasm handles unmapped address"""
108
+ result = disasm(get_unmapped_address())
109
+ assert_is_list(result, min_length=1)
110
+ # Should have error or empty asm
111
+ r = result[0]
112
+ assert r.get("error") is not None or r.get("asm") == "" or r.get("asm") is None
113
+
114
+
115
+ @test()
116
+ def test_disasm_data_segment():
117
+ """disasm handles data segment addresses"""
118
+ data_addr = get_data_address()
119
+ if not data_addr:
120
+ return
121
+
122
+ result = disasm(data_addr)
123
+ assert_is_list(result, min_length=1)
124
+
125
+
126
+ # ============================================================================
127
+ # Tests for xrefs_to
128
+ # ============================================================================
129
+
130
+
131
+ @test()
132
+ def test_xrefs_to():
133
+ """xrefs_to returns cross-references for a function"""
134
+ fn_addrs = get_functions_with_callers()
135
+ if not fn_addrs:
136
+ # Fallback to any function
137
+ fn_addr = get_any_function()
138
+ if not fn_addr:
139
+ return
140
+ else:
141
+ fn_addr = fn_addrs[0]
142
+
143
+ result = xrefs_to(fn_addr)
144
+ assert_is_list(result, min_length=1)
145
+ r = result[0]
146
+ assert_has_keys(r, "addr", "xrefs", "error")
147
+
148
+
149
+ @test()
150
+ def test_xrefs_to_invalid():
151
+ """xrefs_to handles invalid address"""
152
+ result = xrefs_to(get_unmapped_address())
153
+ assert_is_list(result, min_length=1)
154
+ # Should return empty xrefs or error
155
+ r = result[0]
156
+ assert_has_keys(r, "addr")
157
+
158
+
159
+ # ============================================================================
160
+ # Tests for xrefs_to_field
161
+ # ============================================================================
162
+
163
+
164
+ @test()
165
+ def test_xrefs_to_field_nonexistent_struct():
166
+ """xrefs_to_field handles non-existent struct"""
167
+ result = xrefs_to_field({"struct": "NonExistentStruct", "field": "nonexistent"})
168
+ assert_is_list(result, min_length=1)
169
+ r = result[0]
170
+ assert r.get("error") is not None
171
+
172
+
173
+ @test()
174
+ def test_xrefs_to_field_batch():
175
+ """xrefs_to_field handles batch queries"""
176
+ result = xrefs_to_field(
177
+ [
178
+ {"struct": "Struct1", "field": "field1"},
179
+ {"struct": "Struct2", "field": "field2"},
180
+ ]
181
+ )
182
+ assert_is_list(result, min_length=2)
183
+
184
+
185
+ # ============================================================================
186
+ # Tests for callees
187
+ # ============================================================================
188
+
189
+
190
+ @test()
191
+ def test_callees():
192
+ """callees returns functions called by a function"""
193
+ fn_addrs = get_functions_with_calls()
194
+ if not fn_addrs:
195
+ fn_addr = get_any_function()
196
+ if not fn_addr:
197
+ return
198
+ else:
199
+ fn_addr = fn_addrs[0]
200
+
201
+ result = callees(fn_addr)
202
+ assert_is_list(result, min_length=1)
203
+ r = result[0]
204
+ assert_has_keys(r, "addr", "callees", "error")
205
+
206
+
207
+ @test()
208
+ def test_callees_multiple():
209
+ """callees handles multiple addresses"""
210
+ addrs = get_n_functions(3)
211
+ if len(addrs) < 2:
212
+ return
213
+
214
+ result = callees(addrs)
215
+ assert len(result) == len(addrs)
216
+
217
+
218
+ @test()
219
+ def test_callees_invalid_address():
220
+ """callees handles invalid address"""
221
+ result = callees(get_unmapped_address())
222
+ assert_is_list(result, min_length=1)
223
+ r = result[0]
224
+ assert_has_keys(r, "addr")
225
+
226
+
227
+ # ============================================================================
228
+ # Tests for find_bytes
229
+ # ============================================================================
230
+
231
+
232
+ @test()
233
+ def test_find_bytes():
234
+ """find_bytes can search for byte patterns"""
235
+ # Search for common bytes that should exist
236
+ result = find_bytes("00 00")
237
+ assert_is_list(result, min_length=1)
238
+ r = result[0]
239
+ assert_has_keys(r, "query", "matches", "error")
240
+
241
+
242
+ # ============================================================================
243
+ # Tests for basic_blocks
244
+ # ============================================================================
245
+
246
+
247
+ @test()
248
+ def test_basic_blocks():
249
+ """basic_blocks returns blocks for a function"""
250
+ fn_addr = get_any_function()
251
+ if not fn_addr:
252
+ return
253
+
254
+ result = basic_blocks(fn_addr)
255
+ assert_is_list(result, min_length=1)
256
+ r = result[0]
257
+ assert_has_keys(r, "addr", "blocks", "error")
258
+
259
+
260
+ # ============================================================================
261
+ # Tests for find
262
+ # ============================================================================
263
+
264
+
265
+ @test()
266
+ def test_find_string():
267
+ """find can search for strings"""
268
+ # Most binaries have some strings
269
+ result = find("string", query="*")
270
+ assert_is_list(result, min_length=1)
271
+ r = result[0]
272
+ assert_has_keys(r, "query", "matches", "error")
273
+
274
+
275
+ @test()
276
+ def test_find_invalid_type():
277
+ """find handles invalid search type"""
278
+ result = find("invalid_type", query="test")
279
+ assert_is_list(result, min_length=1)
280
+ r = result[0]
281
+ # Should have error for invalid type
282
+ assert r.get("error") is not None
283
+
284
+
285
+ # ============================================================================
286
+ # Tests for export_funcs
287
+ # ============================================================================
288
+
289
+
290
+ @test()
291
+ def test_export_funcs_json():
292
+ """export_funcs returns JSON format"""
293
+ fn_addr = get_any_function()
294
+ if not fn_addr:
295
+ return
296
+
297
+ result = export_funcs(fn_addr, fmt="json")
298
+ assert_is_list(result, min_length=1)
299
+ r = result[0]
300
+ assert_has_keys(r, "addr")
301
+
302
+
303
+ @test()
304
+ def test_export_funcs_c_header():
305
+ """export_funcs returns C header format"""
306
+ fn_addr = get_any_function()
307
+ if not fn_addr:
308
+ return
309
+
310
+ result = export_funcs(fn_addr, fmt="c_header")
311
+ assert_is_list(result, min_length=1)
312
+
313
+
314
+ @test()
315
+ def test_export_funcs_invalid_address():
316
+ """export_funcs handles invalid address"""
317
+ result = export_funcs(get_unmapped_address())
318
+ assert_is_list(result, min_length=1)
319
+
320
+
321
+ # ============================================================================
322
+ # Tests for callgraph
323
+ # ============================================================================
324
+
325
+
326
+ @test()
327
+ def test_callgraph():
328
+ """callgraph returns call graph data"""
329
+ fn_addr = get_any_function()
330
+ if not fn_addr:
331
+ return
332
+
333
+ result = callgraph(fn_addr)
334
+ assert_is_list(result, min_length=1)
335
+ r = result[0]
336
+ assert_has_keys(r, "addr")