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.
- ida_pro_mcp/__init__.py +0 -0
- ida_pro_mcp/__main__.py +6 -0
- ida_pro_mcp/ida_mcp/__init__.py +68 -0
- ida_pro_mcp/ida_mcp/api_analysis.py +1296 -0
- ida_pro_mcp/ida_mcp/api_core.py +337 -0
- ida_pro_mcp/ida_mcp/api_debug.py +617 -0
- ida_pro_mcp/ida_mcp/api_memory.py +304 -0
- ida_pro_mcp/ida_mcp/api_modify.py +406 -0
- ida_pro_mcp/ida_mcp/api_python.py +179 -0
- ida_pro_mcp/ida_mcp/api_resources.py +295 -0
- ida_pro_mcp/ida_mcp/api_stack.py +167 -0
- ida_pro_mcp/ida_mcp/api_types.py +480 -0
- ida_pro_mcp/ida_mcp/auth.py +166 -0
- ida_pro_mcp/ida_mcp/cache.py +232 -0
- ida_pro_mcp/ida_mcp/config.py +228 -0
- ida_pro_mcp/ida_mcp/framework.py +547 -0
- ida_pro_mcp/ida_mcp/http.py +859 -0
- ida_pro_mcp/ida_mcp/port_utils.py +104 -0
- ida_pro_mcp/ida_mcp/rpc.py +187 -0
- ida_pro_mcp/ida_mcp/server_manager.py +339 -0
- ida_pro_mcp/ida_mcp/sync.py +233 -0
- ida_pro_mcp/ida_mcp/tests/__init__.py +14 -0
- ida_pro_mcp/ida_mcp/tests/test_api_analysis.py +336 -0
- ida_pro_mcp/ida_mcp/tests/test_api_core.py +237 -0
- ida_pro_mcp/ida_mcp/tests/test_api_memory.py +207 -0
- ida_pro_mcp/ida_mcp/tests/test_api_modify.py +123 -0
- ida_pro_mcp/ida_mcp/tests/test_api_resources.py +199 -0
- ida_pro_mcp/ida_mcp/tests/test_api_stack.py +77 -0
- ida_pro_mcp/ida_mcp/tests/test_api_types.py +249 -0
- ida_pro_mcp/ida_mcp/ui.py +357 -0
- ida_pro_mcp/ida_mcp/utils.py +1186 -0
- ida_pro_mcp/ida_mcp/zeromcp/__init__.py +5 -0
- ida_pro_mcp/ida_mcp/zeromcp/jsonrpc.py +384 -0
- ida_pro_mcp/ida_mcp/zeromcp/mcp.py +883 -0
- ida_pro_mcp/ida_mcp.py +186 -0
- ida_pro_mcp/idalib_server.py +354 -0
- ida_pro_mcp/idalib_session_manager.py +259 -0
- ida_pro_mcp/server.py +1060 -0
- ida_pro_mcp/test.py +170 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/METADATA +405 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/RECORD +45 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/WHEEL +5 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/entry_points.txt +4 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/licenses/LICENSE +21 -0
- ida_pro_mcp_xjoker-1.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import inspect
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
from typing import Any, Callable, get_type_hints, get_origin, get_args, Union, TypedDict, TypeAlias, NotRequired, is_typeddict
|
|
8
|
+
from types import UnionType
|
|
9
|
+
|
|
10
|
+
JsonRpcId: TypeAlias = str | int | float | None
|
|
11
|
+
|
|
12
|
+
# Thread-local storage for current request context (ID + cancel event)
|
|
13
|
+
_current_request = threading.local()
|
|
14
|
+
|
|
15
|
+
# Global pending requests for cancellation
|
|
16
|
+
_pending_requests_lock = threading.Lock()
|
|
17
|
+
_pending_requests: dict[int | str, threading.Event] = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_current_request_id() -> JsonRpcId:
|
|
21
|
+
"""Get the JSON-RPC request ID of the currently executing request."""
|
|
22
|
+
return getattr(_current_request, "id", None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_current_cancel_event() -> threading.Event | None:
|
|
26
|
+
"""Get the cancel event for the currently executing request."""
|
|
27
|
+
return getattr(_current_request, "cancel_event", None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_pending_request(request_id: int | str) -> threading.Event:
|
|
31
|
+
"""Register a request as pending and return its cancel event."""
|
|
32
|
+
event = threading.Event()
|
|
33
|
+
with _pending_requests_lock:
|
|
34
|
+
_pending_requests[request_id] = event
|
|
35
|
+
_current_request.cancel_event = event
|
|
36
|
+
return event
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def unregister_pending_request(request_id: int | str) -> None:
|
|
40
|
+
"""Unregister a pending request."""
|
|
41
|
+
with _pending_requests_lock:
|
|
42
|
+
_pending_requests.pop(request_id, None)
|
|
43
|
+
_current_request.cancel_event = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def cancel_request(request_id: int | str) -> bool:
|
|
47
|
+
"""Signal cancellation for a pending request. Returns True if request was found."""
|
|
48
|
+
with _pending_requests_lock:
|
|
49
|
+
event = _pending_requests.get(request_id)
|
|
50
|
+
if event:
|
|
51
|
+
event.set()
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_bool_env(name: str, default: bool) -> bool:
|
|
57
|
+
value = os.getenv(name)
|
|
58
|
+
if value is None:
|
|
59
|
+
return default
|
|
60
|
+
value = value.strip().lower()
|
|
61
|
+
if value in ("1", "true", "yes", "on"):
|
|
62
|
+
return True
|
|
63
|
+
if value in ("0", "false", "no", "off"):
|
|
64
|
+
return False
|
|
65
|
+
return default
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_LOG_REQUESTS = _parse_bool_env("IDA_MCP_LOG_REQUESTS", True)
|
|
69
|
+
_LOG_SKIP_METHODS = {
|
|
70
|
+
m.strip()
|
|
71
|
+
for m in os.getenv("IDA_MCP_LOG_SKIP_METHODS", "tools/call").split(",")
|
|
72
|
+
if m.strip()
|
|
73
|
+
}
|
|
74
|
+
JsonRpcParams: TypeAlias = dict[str, Any] | list[Any] | None
|
|
75
|
+
|
|
76
|
+
class JsonRpcRequest(TypedDict):
|
|
77
|
+
jsonrpc: str
|
|
78
|
+
method: str
|
|
79
|
+
params: NotRequired[JsonRpcParams]
|
|
80
|
+
id: NotRequired[JsonRpcId]
|
|
81
|
+
|
|
82
|
+
class JsonRpcError(TypedDict):
|
|
83
|
+
code: int
|
|
84
|
+
message: str
|
|
85
|
+
data: NotRequired[Any]
|
|
86
|
+
|
|
87
|
+
class JsonRpcResponse(TypedDict):
|
|
88
|
+
jsonrpc: str
|
|
89
|
+
result: NotRequired[Any]
|
|
90
|
+
error: NotRequired[JsonRpcError]
|
|
91
|
+
id: JsonRpcId
|
|
92
|
+
|
|
93
|
+
class JsonRpcException(Exception):
|
|
94
|
+
def __init__(self, code: int, message: str, data: Any = None):
|
|
95
|
+
self.code = code
|
|
96
|
+
self.message = message
|
|
97
|
+
self.data = data
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RequestCancelledError(Exception):
|
|
101
|
+
"""Base class for request cancellation errors (LSP error code -32800)."""
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
class JsonRpcRegistry:
|
|
105
|
+
def __init__(self):
|
|
106
|
+
self.methods: dict[str, Callable] = {}
|
|
107
|
+
self._cache: dict[Callable, tuple[inspect.Signature, dict, list[str]]] = {}
|
|
108
|
+
self.redact_exceptions = False
|
|
109
|
+
|
|
110
|
+
def method(self, func: Callable, name: str | None = None) -> Callable:
|
|
111
|
+
self.methods[name or func.__name__] = func # type: ignore
|
|
112
|
+
return func
|
|
113
|
+
|
|
114
|
+
def dispatch(self, request: dict | str | bytes | bytearray) -> JsonRpcResponse | None:
|
|
115
|
+
try:
|
|
116
|
+
if not isinstance(request, dict):
|
|
117
|
+
request = json.loads(request)
|
|
118
|
+
if not isinstance(request, dict):
|
|
119
|
+
return self._error(None, -32600, "Invalid request: must be a JSON object")
|
|
120
|
+
except Exception as e:
|
|
121
|
+
return self._error(None, -32700, "JSON parse error", str(e))
|
|
122
|
+
|
|
123
|
+
if request.get("jsonrpc") != "2.0":
|
|
124
|
+
return self._error(None, -32600, "Invalid request: 'jsonrpc' must be '2.0'")
|
|
125
|
+
|
|
126
|
+
method = request.get("method")
|
|
127
|
+
if method is None:
|
|
128
|
+
return self._error(None, -32600, "Invalid request: 'method' is required")
|
|
129
|
+
if not isinstance(method, str):
|
|
130
|
+
return self._error(None, -32600, "Invalid request: 'method' must be a string")
|
|
131
|
+
|
|
132
|
+
request_id: JsonRpcId = request.get("id")
|
|
133
|
+
is_notification = "id" not in request
|
|
134
|
+
params: JsonRpcParams = request.get("params")
|
|
135
|
+
|
|
136
|
+
log_method = _LOG_REQUESTS and method not in _LOG_SKIP_METHODS
|
|
137
|
+
if log_method:
|
|
138
|
+
params_str = json.dumps(params, default=str)
|
|
139
|
+
if len(params_str) > 200:
|
|
140
|
+
params_str = params_str[:200] + "..."
|
|
141
|
+
print(f"[MCP] >> {method}({params_str})")
|
|
142
|
+
|
|
143
|
+
# Set current request ID in thread-local for cancellation tracking
|
|
144
|
+
_current_request.id = request_id
|
|
145
|
+
start_time = time.perf_counter()
|
|
146
|
+
try:
|
|
147
|
+
result = self._call(method, params)
|
|
148
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
149
|
+
if log_method:
|
|
150
|
+
result_str = json.dumps(result, default=str)
|
|
151
|
+
if len(result_str) > 200:
|
|
152
|
+
result_str = result_str[:200] + "..."
|
|
153
|
+
print(f"[MCP] << {method} ({elapsed_ms:.1f}ms) {result_str}")
|
|
154
|
+
if is_notification:
|
|
155
|
+
return None
|
|
156
|
+
return {
|
|
157
|
+
"jsonrpc": "2.0",
|
|
158
|
+
"result": result,
|
|
159
|
+
"id": request_id,
|
|
160
|
+
}
|
|
161
|
+
except JsonRpcException as e:
|
|
162
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
163
|
+
if log_method:
|
|
164
|
+
print(f"[MCP] << {method} ({elapsed_ms:.1f}ms) ERROR: {e.message}")
|
|
165
|
+
if is_notification:
|
|
166
|
+
return None
|
|
167
|
+
return self._error(request_id, e.code, e.message, e.data)
|
|
168
|
+
except RequestCancelledError as e:
|
|
169
|
+
# LSP error code -32800: Request cancelled
|
|
170
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
171
|
+
if log_method:
|
|
172
|
+
print(f"[MCP] << {method} ({elapsed_ms:.1f}ms) CANCELLED")
|
|
173
|
+
if is_notification:
|
|
174
|
+
return None
|
|
175
|
+
return self._error(request_id, -32800, str(e) or "Request cancelled")
|
|
176
|
+
except Exception as e:
|
|
177
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
178
|
+
if log_method:
|
|
179
|
+
print(f"[MCP] << {method} ({elapsed_ms:.1f}ms) EXCEPTION: {e}")
|
|
180
|
+
if is_notification:
|
|
181
|
+
return None
|
|
182
|
+
error = self.map_exception(e)
|
|
183
|
+
return self._error(request_id, error["code"], error["message"], error.get("data"))
|
|
184
|
+
finally:
|
|
185
|
+
_current_request.id = None
|
|
186
|
+
|
|
187
|
+
def map_exception(self, e: Exception) -> JsonRpcError:
|
|
188
|
+
if self.redact_exceptions:
|
|
189
|
+
return {
|
|
190
|
+
"code": -32603,
|
|
191
|
+
"message": f"Internal Error: {str(e)}",
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
"code": -32603,
|
|
195
|
+
"message": "\n".join(traceback.format_exception(e)).strip() + "\n\nPlease report a bug!",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
def _call(self, method: str, params: Any) -> Any:
|
|
199
|
+
if method not in self.methods:
|
|
200
|
+
raise JsonRpcException(-32601, f"Method '{method}' not found")
|
|
201
|
+
|
|
202
|
+
func = self.methods[method]
|
|
203
|
+
|
|
204
|
+
# Check for cached reflection data
|
|
205
|
+
if func not in self._cache:
|
|
206
|
+
sig = inspect.signature(func)
|
|
207
|
+
hints = get_type_hints(func)
|
|
208
|
+
hints.pop("return", None)
|
|
209
|
+
|
|
210
|
+
# Determine required vs optional parameters
|
|
211
|
+
required_params = []
|
|
212
|
+
for param_name, param in sig.parameters.items():
|
|
213
|
+
if param.default is inspect.Parameter.empty:
|
|
214
|
+
required_params.append(param_name)
|
|
215
|
+
|
|
216
|
+
self._cache[func] = (sig, hints, required_params)
|
|
217
|
+
|
|
218
|
+
sig, hints, required_params = self._cache[func]
|
|
219
|
+
|
|
220
|
+
# Handle None params
|
|
221
|
+
if params is None:
|
|
222
|
+
if len(required_params) == 0:
|
|
223
|
+
return func()
|
|
224
|
+
else:
|
|
225
|
+
raise JsonRpcException(-32602, "Missing required params")
|
|
226
|
+
|
|
227
|
+
# Convert list params to dict by parameter names
|
|
228
|
+
if isinstance(params, list):
|
|
229
|
+
if len(params) < len(required_params):
|
|
230
|
+
raise JsonRpcException(
|
|
231
|
+
-32602,
|
|
232
|
+
f"Invalid params: expected at least {len(required_params)} arguments, got {len(params)}"
|
|
233
|
+
)
|
|
234
|
+
if len(params) > len(sig.parameters):
|
|
235
|
+
raise JsonRpcException(
|
|
236
|
+
-32602,
|
|
237
|
+
f"Invalid params: expected at most {len(sig.parameters)} arguments, got {len(params)}"
|
|
238
|
+
)
|
|
239
|
+
params = dict(zip(sig.parameters.keys(), params))
|
|
240
|
+
|
|
241
|
+
# Validate dict params
|
|
242
|
+
if isinstance(params, dict):
|
|
243
|
+
# Check all required params are present
|
|
244
|
+
missing = set(required_params) - set(params.keys())
|
|
245
|
+
if missing:
|
|
246
|
+
raise JsonRpcException(
|
|
247
|
+
-32602,
|
|
248
|
+
f"Invalid params: missing required parameters: {list(missing)}"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Check no extra params
|
|
252
|
+
extra = set(params.keys()) - set(sig.parameters.keys())
|
|
253
|
+
if extra:
|
|
254
|
+
raise JsonRpcException(
|
|
255
|
+
-32602,
|
|
256
|
+
f"Invalid params: unexpected parameters: {list(extra)}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
validated_params = {}
|
|
260
|
+
for param_name, value in params.items():
|
|
261
|
+
# If no type hint, pass through without validation
|
|
262
|
+
if param_name not in hints:
|
|
263
|
+
validated_params[param_name] = value
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Has type hint, validate
|
|
267
|
+
expected_type = hints[param_name]
|
|
268
|
+
|
|
269
|
+
# Inline type validation
|
|
270
|
+
origin = get_origin(expected_type)
|
|
271
|
+
args = get_args(expected_type)
|
|
272
|
+
|
|
273
|
+
# Handle None/null
|
|
274
|
+
if value is None:
|
|
275
|
+
if expected_type is not type(None):
|
|
276
|
+
# Check if None is allowed in a Union
|
|
277
|
+
if not (origin in (Union, UnionType) and type(None) in args):
|
|
278
|
+
raise JsonRpcException(-32602, f"Invalid params: {param_name} cannot be null")
|
|
279
|
+
validated_params[param_name] = None
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
# Handle Union types (int | str, Optional[int], etc.)
|
|
283
|
+
if origin in (Union, UnionType):
|
|
284
|
+
type_matched = False
|
|
285
|
+
|
|
286
|
+
# HACK: Try to parse str as JSON for non-str unions
|
|
287
|
+
#
|
|
288
|
+
# When JSON schema says one field is "object", Claude Code
|
|
289
|
+
# (and maybe other MCP clients) can't (or won't) detect
|
|
290
|
+
# that the field is actually a dict/list. Instead, they
|
|
291
|
+
# treat the field as a string containing JSON object.
|
|
292
|
+
#
|
|
293
|
+
# To work around this, if the expected type is a Union
|
|
294
|
+
# that does not include str, and the provided value is
|
|
295
|
+
# a str, we try to parse it as JSON first.
|
|
296
|
+
if type(str) not in args and isinstance(value, str):
|
|
297
|
+
try:
|
|
298
|
+
value = json.loads(value)
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
pass
|
|
301
|
+
|
|
302
|
+
for arg_type in args:
|
|
303
|
+
if arg_type is type(None):
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
arg_origin = get_origin(arg_type)
|
|
307
|
+
check_type = arg_origin if arg_origin is not None else arg_type
|
|
308
|
+
|
|
309
|
+
# TypedDict cannot be used with isinstance - check for dict instead
|
|
310
|
+
if is_typeddict(arg_type):
|
|
311
|
+
check_type = dict
|
|
312
|
+
|
|
313
|
+
if isinstance(value, check_type):
|
|
314
|
+
type_matched = True
|
|
315
|
+
break
|
|
316
|
+
|
|
317
|
+
if not type_matched:
|
|
318
|
+
raise JsonRpcException(-32602, "Invalid params: expected {} for {}, got {}".format(
|
|
319
|
+
" | ".join(
|
|
320
|
+
t.__name__ if isinstance(t, type) else str(t)
|
|
321
|
+
for t in args
|
|
322
|
+
),
|
|
323
|
+
param_name,
|
|
324
|
+
type(value).__name__
|
|
325
|
+
))
|
|
326
|
+
validated_params[param_name] = value
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
# Handle generic types (list[X], dict[K,V])
|
|
330
|
+
if origin is not None:
|
|
331
|
+
if not isinstance(value, origin):
|
|
332
|
+
raise JsonRpcException(
|
|
333
|
+
-32602,
|
|
334
|
+
f"Invalid params: {param_name} expected {origin.__name__}, got {type(value).__name__}"
|
|
335
|
+
)
|
|
336
|
+
validated_params[param_name] = value
|
|
337
|
+
continue
|
|
338
|
+
|
|
339
|
+
# Handle TypedDict (must check before basic types)
|
|
340
|
+
if is_typeddict(expected_type):
|
|
341
|
+
if not isinstance(value, dict):
|
|
342
|
+
raise JsonRpcException(
|
|
343
|
+
-32602,
|
|
344
|
+
f"Invalid params: {param_name} expected dict, got {type(value).__name__}"
|
|
345
|
+
)
|
|
346
|
+
validated_params[param_name] = value
|
|
347
|
+
continue
|
|
348
|
+
|
|
349
|
+
# Handle Any
|
|
350
|
+
if expected_type is Any:
|
|
351
|
+
validated_params[param_name] = value
|
|
352
|
+
continue
|
|
353
|
+
|
|
354
|
+
# Handle basic types
|
|
355
|
+
if isinstance(expected_type, type):
|
|
356
|
+
# Allow int -> float conversion
|
|
357
|
+
if expected_type is float and isinstance(value, int):
|
|
358
|
+
validated_params[param_name] = float(value)
|
|
359
|
+
continue
|
|
360
|
+
if not isinstance(value, expected_type):
|
|
361
|
+
raise JsonRpcException(
|
|
362
|
+
-32602,
|
|
363
|
+
f"Invalid params: {param_name} expected {expected_type.__name__}, got {type(value).__name__}"
|
|
364
|
+
)
|
|
365
|
+
validated_params[param_name] = value
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
return func(**validated_params)
|
|
369
|
+
|
|
370
|
+
else:
|
|
371
|
+
raise JsonRpcException(-32602, "Invalid params: must be array or object")
|
|
372
|
+
|
|
373
|
+
def _error(self, request_id: JsonRpcId, code: int, message: str, data: Any = None) -> JsonRpcResponse | None:
|
|
374
|
+
error: JsonRpcError = {
|
|
375
|
+
"code": code,
|
|
376
|
+
"message": message,
|
|
377
|
+
}
|
|
378
|
+
if data is not None:
|
|
379
|
+
error["data"] = data
|
|
380
|
+
return {
|
|
381
|
+
"jsonrpc": "2.0",
|
|
382
|
+
"error": error,
|
|
383
|
+
"id": request_id,
|
|
384
|
+
}
|