ida-code 0.2.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_code/server.py ADDED
@@ -0,0 +1,1011 @@
1
+ import argparse
2
+ import hmac
3
+ import logging
4
+ import secrets
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from fastmcp import FastMCP
10
+ from fastmcp.exceptions import ToolError
11
+
12
+ from ida_code import guidelines as _guidelines
13
+ from ida_code import macho as _macho
14
+ from ida_code import session
15
+ from ida_code.config import LOG_LEVEL, MCP_AUTH_TOKEN
16
+ from ida_code.executor import execute as _execute
17
+ from ida_code.doc_search import search as _search_docs
18
+ from ida_code.example_search import search as _search_examples
19
+ from ida_code import comments as _comments
20
+ from ida_code import snapshots as _snapshots
21
+ from ida_code import structures as _structures
22
+ from ida_code import undo as _undo
23
+ from ida_code import variables as _variables
24
+ from ida_code import prompts as _prompts
25
+
26
+ CommentType = Literal["regular", "repeatable", "function", "anterior", "posterior"]
27
+ CommentTypeOrAll = Literal["regular", "repeatable", "function", "anterior", "posterior", ""]
28
+ ExampleCategory = Literal["ui", "disassembler", "decompiler", "debugger", "types", "misc", ""]
29
+ ExampleLevel = Literal["beginner", "intermediate", "advanced", ""]
30
+
31
+ mcp = FastMCP(
32
+ "ida-code",
33
+ instructions=(
34
+ "IDA Pro reverse engineering server. Open binaries, decompile functions, "
35
+ "annotate code, and run IDAPython scripts.\n\n"
36
+ "Typical workflow: open_database → list_functions → decompile → "
37
+ "annotate (rename_function, set_comment, set_variable) → iterate.\n\n"
38
+ "Only one database can be open at a time. Most tools require an open database."
39
+ ),
40
+ )
41
+
42
+
43
+ @mcp.tool
44
+ def list_architectures(path: str) -> list[str]:
45
+ """List architecture slices in a fat (universal) Mach-O binary.
46
+
47
+ Returns e.g. ["x86_64", "arm64e"]. Returns an empty list if the file
48
+ is not a fat Mach-O. No database needs to be open.
49
+
50
+ Use this to discover available slices before calling open_database
51
+ with the *arch* parameter.
52
+ """
53
+ return _macho.list_architectures(path)
54
+
55
+
56
+ @mcp.tool
57
+ def open_database(
58
+ path: str,
59
+ auto_analysis: bool = True,
60
+ overwrite: bool = False,
61
+ timeout: int = 0,
62
+ arch: str | None = None,
63
+ ) -> dict:
64
+ """Open a binary or IDA database via idalib.
65
+
66
+ Returns summary info (architecture, segments, entry points, function count).
67
+ If a database is already open, it is closed first.
68
+
69
+ Set overwrite=True to delete any existing .i64/.idb database and force
70
+ a fresh analysis from the original binary.
71
+
72
+ *timeout* limits auto-analysis wait time in seconds (default 0 = unlimited).
73
+ When the timeout expires the database stays open with partial analysis
74
+ and a warning is appended to the summary.
75
+
76
+ *arch* selects a specific architecture slice from a fat (universal) Mach-O
77
+ binary (e.g. "arm64e", "x86_64"). Use list_architectures to discover
78
+ available slices. Ignored for non-fat binaries.
79
+ """
80
+ return session.open(path, auto_analysis, overwrite, timeout=timeout, arch=arch)
81
+
82
+
83
+ @mcp.tool
84
+ def get_database_info() -> dict:
85
+ """Return summary info about the current database.
86
+
87
+ Returns processor type, bitness, segments, entry points, and function count
88
+ without opening or closing anything. If no database is open, says so.
89
+ """
90
+ return session.info()
91
+
92
+
93
+ @mcp.tool
94
+ def close_database() -> dict:
95
+ """Close the current database and free resources. Requires an open database.
96
+
97
+ The executor namespace is cleared. No database will be open after this call.
98
+ """
99
+ session.require_open()
100
+ session.close()
101
+ return {"status": "closed"}
102
+
103
+
104
+ @mcp.tool
105
+ def execute(code: str, timeout: int = 30) -> dict:
106
+ """Execute IDAPython code and return captured output. Requires an open database.
107
+
108
+ The execution namespace persists across calls — variables and functions defined
109
+ in one call are available in subsequent calls. Pre-imported modules:
110
+ ``ida_funcs``, ``ida_bytes``, ``ida_name``, ``ida_segment``, ``ida_auto``,
111
+ ``ida_idaapi``, ``ida_nalt``, ``ida_xref``, ``ida_ua``, ``ida_entry``,
112
+ ``ida_lines``, ``ida_typeinf``, ``ida_hexrays``, ``idautils``, ``idc``.
113
+
114
+ Python tracebacks are returned as normal output for debugging.
115
+
116
+ *timeout* sets the maximum wall-clock seconds (default 30, 0 = unlimited).
117
+
118
+ Returns: ``{"output", "truncated"}``
119
+ """
120
+ session.require_open()
121
+ text = _execute(code, timeout=timeout)
122
+ return {"output": text, "truncated": len(text) >= 50000}
123
+
124
+
125
+ @mcp.tool
126
+ def execute_file(path: str, args: str | None = None, timeout: int = 30) -> dict:
127
+ """Execute an IDAPython script file and return captured output. Requires an open database.
128
+
129
+ Reads the file at `path` and executes it. Optionally, `args` provides
130
+ inline code that runs after the file in the same namespace — useful for
131
+ calling functions defined in the script or inspecting results.
132
+
133
+ The execution namespace persists across calls, same as `execute`.
134
+
135
+ *timeout* sets the maximum wall-clock seconds (default 30, 0 = unlimited).
136
+
137
+ Returns: ``{"output", "truncated"}``
138
+ """
139
+ session.require_open()
140
+
141
+ p = Path(path)
142
+ if not p.is_file():
143
+ raise ToolError(f"File not found: {path}")
144
+ try:
145
+ code = p.read_text(errors="replace")
146
+ except OSError as e:
147
+ raise ToolError(f"Could not read file: {e}")
148
+
149
+ if args:
150
+ code = code + "\n" + args
151
+
152
+ text = _execute(code, timeout=timeout)
153
+ return {"output": text, "truncated": len(text) >= 50000}
154
+
155
+
156
+ def _resolve_address(identifier: str) -> int:
157
+ """Resolve a name or numeric string to an address.
158
+
159
+ Tries hex (with or without ``0x``), then decimal, then IDA name lookup.
160
+ Returns ``ida_idaapi.BADADDR`` on failure.
161
+ """
162
+ import ida_idaapi
163
+ import ida_name
164
+
165
+ ea = ida_idaapi.BADADDR
166
+ s = identifier.strip()
167
+
168
+ # Try as hex address first (with or without 0x prefix).
169
+ try:
170
+ ea = int(s, 16)
171
+ except ValueError:
172
+ pass
173
+
174
+ # Try as decimal address.
175
+ if ea == ida_idaapi.BADADDR:
176
+ try:
177
+ ea = int(s, 10)
178
+ except ValueError:
179
+ pass
180
+
181
+ # Try as a name.
182
+ if ea == ida_idaapi.BADADDR:
183
+ ea = ida_name.get_name_ea(ida_idaapi.BADADDR, s)
184
+
185
+ return ea
186
+
187
+
188
+ @mcp.tool
189
+ def decompile(function: str, max_length: int = 10000, offset: int = 0) -> dict:
190
+ """Decompile a function and return pseudocode. Requires an open database.
191
+
192
+ *function* can be a name (e.g. "main", "_objc_msgSend") or a hex address
193
+ (e.g. "0x3f08", "3f08"). The address must fall within a recognized function.
194
+
195
+ Requires the Hex-Rays decompiler.
196
+
197
+ *max_length* caps the pseudocode returned (default 10000 chars).
198
+ *offset* starts from this character position (for paging).
199
+ If ``truncated`` is true, call again with ``offset=<offset + max_length>``
200
+ to get the next page.
201
+ """
202
+ session.require_open()
203
+
204
+ import ida_funcs
205
+ import ida_hexrays
206
+ import ida_idaapi
207
+
208
+ ea = _resolve_address(function)
209
+ if ea == ida_idaapi.BADADDR:
210
+ raise ToolError(f"Could not resolve '{function}' to an address.")
211
+
212
+ # Ensure ea is within a function.
213
+ pfn = ida_funcs.get_func(ea)
214
+ if pfn is None:
215
+ raise ToolError(f"Address {ea:#x} is not within a recognized function.")
216
+
217
+ try:
218
+ cfunc = ida_hexrays.decompile(pfn.start_ea)
219
+ except ida_hexrays.DecompilationFailure as e:
220
+ raise ToolError(f"Decompilation failed: {e}")
221
+ except Exception as e:
222
+ raise ToolError(f"{type(e).__name__}: {e}")
223
+
224
+ if cfunc is None:
225
+ raise ToolError("Decompilation returned no result.")
226
+
227
+ func_name = ida_funcs.get_func_name(pfn.start_ea) or f"sub_{pfn.start_ea:x}"
228
+ pseudocode = str(cfunc)
229
+ total_length = len(pseudocode)
230
+ chunk = pseudocode[offset:offset + max_length]
231
+ return {
232
+ "name": func_name,
233
+ "address": f"{pfn.start_ea:#x}",
234
+ "size": f"{pfn.end_ea - pfn.start_ea:#x}",
235
+ "pseudocode": chunk,
236
+ "offset": offset,
237
+ "total_length": total_length,
238
+ "truncated": offset + max_length < total_length,
239
+ }
240
+
241
+
242
+ @mcp.tool
243
+ def get_disassembly(start: str, length: int = 0x100) -> dict:
244
+ """Get disassembly for an address range. Requires an open database.
245
+
246
+ *start* can be a name (e.g. "main") or address (hex "0x3f08" / "3f08",
247
+ decimal "16136"). *length* is the number of bytes from start to disassemble
248
+ (default 256, capped at 64 KB).
249
+
250
+ Returns: ``{"start", "end", "count", "instructions": [{"address", "disasm"}]}``
251
+ """
252
+ session.require_open()
253
+
254
+ import ida_idaapi
255
+ import idc
256
+ import idautils
257
+
258
+ ea = _resolve_address(start)
259
+ if ea == ida_idaapi.BADADDR:
260
+ raise ToolError(f"Could not resolve '{start}' to an address.")
261
+
262
+ length = max(1, min(length, 0x10000))
263
+ end_ea = ea + length
264
+
265
+ instructions: list[dict] = []
266
+ for head in idautils.Heads(ea, end_ea):
267
+ instructions.append({"address": f"{head:#x}", "disasm": idc.GetDisasm(head)})
268
+
269
+ if not instructions:
270
+ raise ToolError(f"No instructions found in range {ea:#x}\u2013{end_ea:#x}.")
271
+
272
+ return {
273
+ "start": f"{ea:#x}",
274
+ "end": f"{end_ea:#x}",
275
+ "count": len(instructions),
276
+ "instructions": instructions,
277
+ }
278
+
279
+
280
+ @mcp.tool
281
+ def list_functions(offset: int = 0, limit: int = 50, name_filter: str = "") -> dict:
282
+ """List functions in the database with pagination. Requires an open database.
283
+
284
+ Returns address, size, and name for each function.
285
+
286
+ *offset* skips the first N functions (for pagination).
287
+ *limit* caps the number of functions returned (default 50, max 1000).
288
+ *name_filter* if non-empty, only includes functions whose name contains
289
+ this substring (case-insensitive).
290
+
291
+ If more results exist, increase *offset* by *limit* to get the next page.
292
+
293
+ Returns: ``{"functions": [{"address", "size", "name"}], "total", "showing", "offset", "name_filter"}``
294
+ """
295
+ session.require_open()
296
+
297
+ import ida_funcs
298
+ import idautils
299
+
300
+ limit = min(limit, 1000)
301
+ all_funcs = list(idautils.Functions())
302
+ total = len(all_funcs)
303
+
304
+ functions: list[dict] = []
305
+ skipped = 0
306
+ for ea in all_funcs:
307
+ name = ida_funcs.get_func_name(ea) or f"sub_{ea:x}"
308
+ if name_filter and name_filter.lower() not in name.lower():
309
+ continue
310
+ if skipped < offset:
311
+ skipped += 1
312
+ continue
313
+ pfn = ida_funcs.get_func(ea)
314
+ size = pfn.end_ea - pfn.start_ea if pfn else 0
315
+ functions.append({"address": f"{ea:#x}", "size": f"{size:#x}", "name": name})
316
+ if len(functions) >= limit:
317
+ break
318
+
319
+ return {
320
+ "functions": functions,
321
+ "total": total,
322
+ "showing": len(functions),
323
+ "offset": offset,
324
+ "name_filter": name_filter,
325
+ }
326
+
327
+
328
+ @mcp.tool
329
+ def search_docs(
330
+ query: str,
331
+ max_results: int = 5,
332
+ max_snippet_length: int = 150,
333
+ include_examples: bool = True,
334
+ ) -> dict:
335
+ """Look up IDA API functions, constants, and usage. No database needs to be open.
336
+
337
+ Use this to find the right API for a task, check function signatures,
338
+ or understand parameter meanings.
339
+
340
+ Searches two corpora:
341
+ - IDA HTML documentation (developer guide, user guide, etc.)
342
+ - IDAPython API source files (ida_*.py function signatures and docstrings)
343
+
344
+ Uses word-boundary matching: "set" matches "set_name" but not "reset".
345
+
346
+ When *include_examples* is True (default), also returns up to 2 matching
347
+ example scripts in the ``related_examples`` key.
348
+
349
+ *max_snippet_length* caps each snippet (default 150 chars).
350
+ """
351
+ return _search_docs(query, max_results, max_snippet_length, include_examples)
352
+
353
+
354
+ @mcp.tool
355
+ def search_examples(
356
+ query: str,
357
+ max_results: int = 5,
358
+ max_snippet_lines: int = 10,
359
+ category: ExampleCategory = "",
360
+ level: ExampleLevel = "",
361
+ ) -> dict:
362
+ """Find working IDAPython code examples for common tasks. No database needs to be open.
363
+
364
+ Use this to find code patterns (e.g. "list strings", "decompile",
365
+ "enumerate imports") or see how specific IDA APIs are used in practice.
366
+
367
+ Searches example titles, descriptions, keywords, APIs used, and source code.
368
+ For API signatures and documentation, use `search_docs` instead.
369
+
370
+ *max_snippet_lines* caps source snippets (default 10 lines).
371
+ """
372
+ return _search_examples(query, max_results, category, level, max_snippet_lines)
373
+
374
+
375
+ @mcp.tool
376
+ def list_snapshots() -> dict:
377
+ """List all database snapshots. Requires an open database.
378
+
379
+ Returns snapshot IDs, descriptions, and filenames for the current database.
380
+
381
+ Returns: ``{"snapshots": [{"id", "desc", "filename"}], "count"}``
382
+ """
383
+ return _snapshots.list_snapshots()
384
+
385
+
386
+ @mcp.tool
387
+ def create_snapshot(desc: str = "") -> dict:
388
+ """Create a database snapshot to checkpoint the current state. Requires an open database.
389
+
390
+ Snapshots let you save the database state before making destructive changes
391
+ (renaming, patching, type changes) and roll back if needed.
392
+
393
+ *desc* is an optional short description (max 128 chars).
394
+
395
+ Returns: ``{"id", "desc", "filename"}``
396
+ """
397
+ return _snapshots.create_snapshot(desc)
398
+
399
+
400
+ @mcp.tool
401
+ def restore_snapshot(snapshot_id: str) -> dict:
402
+ """Restore the database to a previous snapshot. Requires an open database.
403
+
404
+ *snapshot_id* is the snapshot ID from list_snapshots or create_snapshot.
405
+ The executor namespace is reset after restore since the database state changed.
406
+ """
407
+ return _snapshots.restore_snapshot(snapshot_id)
408
+
409
+
410
+ @mcp.tool
411
+ def delete_snapshot(snapshot_id: str) -> dict:
412
+ """Delete a database snapshot by removing its file from disk. Requires an open database.
413
+
414
+ *snapshot_id* is the snapshot ID from list_snapshots or create_snapshot.
415
+ """
416
+ return _snapshots.remove_snapshot(snapshot_id)
417
+
418
+
419
+ @mcp.tool
420
+ def get_undo_status() -> dict:
421
+ """Check what undo/redo actions are available. Requires an open database.
422
+
423
+ Returns whether undo and redo are possible, along with labels describing
424
+ the next undo/redo actions. IDA only exposes the *next* action in each
425
+ direction, not the full history stack.
426
+
427
+ Returns: ``{"can_undo", "undo_action", "can_redo", "redo_action"}``
428
+ """
429
+ return _undo.get_status()
430
+
431
+
432
+ @mcp.tool
433
+ def perform_undo(steps: int = 1) -> dict:
434
+ """Undo the last database action(s). Requires an open database.
435
+
436
+ *steps* is how many undo steps to perform (default 1). If fewer steps
437
+ are available than requested, performs as many as possible (partial success).
438
+
439
+ The executor namespace is reset after undo since the database state changed.
440
+
441
+ Returns: ``{"status", "steps_requested", "steps_performed", "actions", "next_undo", "next_redo"}``
442
+ """
443
+ return _undo.perform_undo(steps)
444
+
445
+
446
+ @mcp.tool
447
+ def perform_redo(steps: int = 1) -> dict:
448
+ """Redo the last undone database action(s). Requires an open database.
449
+
450
+ *steps* is how many redo steps to perform (default 1). If fewer steps
451
+ are available than requested, performs as many as possible (partial success).
452
+
453
+ The executor namespace is reset after redo since the database state changed.
454
+
455
+ Returns: ``{"status", "steps_requested", "steps_performed", "actions", "next_undo", "next_redo"}``
456
+ """
457
+ return _undo.perform_redo(steps)
458
+
459
+
460
+ @mcp.tool
461
+ def list_structures(offset: int = 0, limit: int = 50, name_filter: str = "") -> dict:
462
+ """List structures (structs/unions) in the database with pagination. Requires an open database.
463
+
464
+ Returns name, size, alignment, and member count for each structure.
465
+
466
+ *offset* skips the first N matching structures (for pagination).
467
+ *limit* caps the number returned (default 50, max 1000).
468
+ *name_filter* if non-empty, only includes structures whose name contains
469
+ this substring (case-insensitive).
470
+
471
+ Returns: ``{"structures": [{"name", "size", "alignment", "member_count"}], "total", "showing", "offset", "name_filter"}``
472
+ """
473
+ return _structures.list_structures(offset, min(limit, 1000), name_filter)
474
+
475
+
476
+ @mcp.tool
477
+ def get_structure(name: str) -> dict:
478
+ """Get detailed info about a structure (struct/union) by name. Requires an open database.
479
+
480
+ Returns name, size, alignment, is_union flag, member count, and the full
481
+ C definition with ``/* offset */`` comments on each field.
482
+
483
+ Returns: ``{"name", "size", "is_union", "alignment", "member_count", "definition"}``
484
+ """
485
+ return _structures.get_structure(name)
486
+
487
+
488
+ @mcp.tool
489
+ def create_structure(definition: str) -> dict:
490
+ """Create a new structure from a C definition string. Requires an open database.
491
+
492
+ *definition* is a valid C struct or union definition, e.g.:
493
+ ``struct foo { int x; char *y; };``
494
+
495
+ Fails if a structure with the same name already exists.
496
+ Returns the newly created structure details.
497
+ """
498
+ return _structures.create_structure(definition)
499
+
500
+
501
+ @mcp.tool
502
+ def edit_structure(definition: str) -> dict:
503
+ """Edit an existing structure by replacing its C definition. Requires an open database.
504
+
505
+ *definition* is a valid C struct or union definition with the same name
506
+ as an existing structure. The old definition is fully replaced.
507
+
508
+ Fails if the structure does not exist.
509
+ Returns the updated structure details.
510
+ """
511
+ return _structures.edit_structure(definition)
512
+
513
+
514
+ @mcp.tool
515
+ def delete_structure(name: str) -> dict:
516
+ """Delete a structure (struct/union) by name. Requires an open database.
517
+
518
+ Removes the named type from the database type library.
519
+ Fails if the structure does not exist.
520
+ """
521
+ return _structures.delete_structure(name)
522
+
523
+
524
+ @mcp.tool
525
+ def get_variable(name: str, scope: str | None = None) -> dict:
526
+ """Get info about a variable by name. Requires an open database.
527
+
528
+ If *scope* is provided, looks up a **local** (decompiler) variable
529
+ within that function. *scope* can be a function name (e.g. "main") or hex
530
+ address (e.g. "0x3f08").
531
+
532
+ If *scope* is omitted, resolves *name* as a **global** variable
533
+ (symbol name or address).
534
+
535
+ Local variables require Hex-Rays.
536
+
537
+ Returns (local): ``{"name", "type", "width", "is_arg", "function", "scope": "local"}``
538
+ Returns (global): ``{"name", "type", "address", "scope": "global"}``
539
+ """
540
+ session.require_open()
541
+
542
+ import ida_idaapi
543
+
544
+ if scope is not None:
545
+ func_ea = _resolve_address(scope)
546
+ if func_ea == ida_idaapi.BADADDR:
547
+ raise ToolError(f"Could not resolve function '{scope}' to an address.")
548
+ return _variables.get_local_variable(func_ea, name)
549
+ else:
550
+ ea = _resolve_address(name)
551
+ if ea == ida_idaapi.BADADDR:
552
+ raise ToolError(f"Could not resolve '{name}' to an address.")
553
+ return _variables.get_global_variable(ea)
554
+
555
+
556
+ @mcp.tool
557
+ def set_variable(
558
+ name: str,
559
+ scope: str | None = None,
560
+ new_name: str | None = None,
561
+ new_type: str | None = None,
562
+ ) -> dict:
563
+ """Rename and/or retype a variable. Requires an open database.
564
+
565
+ If *scope* is provided, modifies a **local** (decompiler) variable
566
+ within that function. *scope* can be a function name or hex address.
567
+
568
+ If *scope* is omitted, modifies a **global** variable.
569
+ *name* is resolved as a symbol name or address.
570
+
571
+ At least one of *new_name* or *new_type* must be provided.
572
+ Local variables require Hex-Rays.
573
+ """
574
+ session.require_open()
575
+
576
+ if new_name is None and new_type is None:
577
+ raise ToolError("At least one of new_name or new_type must be provided.")
578
+
579
+ import ida_idaapi
580
+
581
+ if scope is not None:
582
+ func_ea = _resolve_address(scope)
583
+ if func_ea == ida_idaapi.BADADDR:
584
+ raise ToolError(f"Could not resolve function '{scope}' to an address.")
585
+ return _variables.set_local_variable(func_ea, name, new_name, new_type)
586
+ else:
587
+ ea = _resolve_address(name)
588
+ if ea == ida_idaapi.BADADDR:
589
+ raise ToolError(f"Could not resolve '{name}' to an address.")
590
+ return _variables.set_global_variable(ea, new_name, new_type)
591
+
592
+
593
+ @mcp.tool
594
+ def get_comment(address: str, comment_type: CommentTypeOrAll = "") -> dict:
595
+ """Get comment(s) at an address. Requires an open database.
596
+
597
+ *address* can be a name (e.g. "main") or hex address (e.g. "0x3f08").
598
+
599
+ *comment_type* selects which comment to read, or empty string (default)
600
+ to return all non-empty comment types at once.
601
+
602
+ - **regular** — inline comment on a disassembly line
603
+ - **repeatable** — inline comment that propagates to cross-references
604
+ - **function** — comment on the function header (ea must be in a function)
605
+ - **anterior** — multi-line block before the address
606
+ - **posterior** — multi-line block after the address
607
+ """
608
+ session.require_open()
609
+
610
+ import ida_idaapi
611
+
612
+ ea = _resolve_address(address)
613
+ if ea == ida_idaapi.BADADDR:
614
+ raise ToolError(f"Could not resolve '{address}' to an address.")
615
+ return _comments.get_comment(ea, comment_type)
616
+
617
+
618
+ @mcp.tool
619
+ def set_comment(address: str, comment: str, comment_type: CommentType = "regular") -> dict:
620
+ """Set a comment at an address. Requires an open database.
621
+
622
+ *address* can be a name or hex address.
623
+ *comment* is the comment text (use ``\\n`` for multi-line anterior/posterior).
624
+
625
+ Returns: ``{"address", "comment_type", "comment", "status": "updated"}``
626
+ """
627
+ session.require_open()
628
+
629
+ import ida_idaapi
630
+
631
+ ea = _resolve_address(address)
632
+ if ea == ida_idaapi.BADADDR:
633
+ raise ToolError(f"Could not resolve '{address}' to an address.")
634
+ return _comments.set_comment(ea, comment, comment_type)
635
+
636
+
637
+ @mcp.tool
638
+ def delete_comment(address: str, comment_type: CommentType = "regular") -> dict:
639
+ """Delete a comment at an address. Requires an open database.
640
+
641
+ *address* can be a name or hex address.
642
+
643
+ Returns: ``{"address", "comment_type", "status": "deleted"}``
644
+ """
645
+ session.require_open()
646
+
647
+ import ida_idaapi
648
+
649
+ ea = _resolve_address(address)
650
+ if ea == ida_idaapi.BADADDR:
651
+ raise ToolError(f"Could not resolve '{address}' to an address.")
652
+ return _comments.delete_comment(ea, comment_type)
653
+
654
+
655
+ @mcp.tool
656
+ def rename_function(function: str, new_name: str) -> dict:
657
+ """Rename a function. Requires an open database.
658
+
659
+ *function* can be a name (e.g. "sub_3f08") or hex address (e.g. "0x3f08").
660
+ *new_name* is the new function name.
661
+
662
+ Returns: ``{"address", "old_name", "new_name", "status": "renamed"}``
663
+ """
664
+ session.require_open()
665
+
666
+ import ida_funcs
667
+ import ida_idaapi
668
+ import ida_name
669
+
670
+ ea = _resolve_address(function)
671
+ if ea == ida_idaapi.BADADDR:
672
+ raise ToolError(f"Could not resolve '{function}' to an address.")
673
+
674
+ pfn = ida_funcs.get_func(ea)
675
+ if pfn is None:
676
+ raise ToolError(f"Address {ea:#x} is not within a recognized function.")
677
+
678
+ old_name = ida_funcs.get_func_name(pfn.start_ea) or f"sub_{pfn.start_ea:x}"
679
+ ok = ida_name.set_name(pfn.start_ea, new_name, ida_name.SN_NOCHECK)
680
+ if not ok:
681
+ raise ToolError(f"Failed to rename function at {pfn.start_ea:#x} to '{new_name}'.")
682
+
683
+ return {
684
+ "address": f"{pfn.start_ea:#x}",
685
+ "old_name": old_name,
686
+ "new_name": new_name,
687
+ "status": "renamed",
688
+ }
689
+
690
+
691
+ @mcp.tool
692
+ def retype_function(function: str, new_type: str) -> dict:
693
+ """Change a function's type signature. Requires an open database.
694
+
695
+ *function* can be a name (e.g. "main") or hex address (e.g. "0x3f08").
696
+ *new_type* is a C function type string (e.g. "int __fastcall(int argc, char **argv)").
697
+
698
+ Returns: ``{"address", "name", "old_type", "new_type", "status": "retyped"}``
699
+ """
700
+ session.require_open()
701
+
702
+ import ida_funcs
703
+ import ida_idaapi
704
+ import ida_typeinf
705
+ import idc
706
+
707
+ ea = _resolve_address(function)
708
+ if ea == ida_idaapi.BADADDR:
709
+ raise ToolError(f"Could not resolve '{function}' to an address.")
710
+
711
+ pfn = ida_funcs.get_func(ea)
712
+ if pfn is None:
713
+ raise ToolError(f"Address {ea:#x} is not within a recognized function.")
714
+
715
+ func_name = ida_funcs.get_func_name(pfn.start_ea) or f"sub_{pfn.start_ea:x}"
716
+
717
+ # Get old type.
718
+ old_type = idc.get_type(pfn.start_ea) or ""
719
+
720
+ # Apply new type.
721
+ ok = idc.SetType(pfn.start_ea, new_type)
722
+ if not ok:
723
+ raise ToolError(
724
+ f"Failed to apply type '{new_type}' to function '{func_name}' at {pfn.start_ea:#x}. "
725
+ "Check C syntax."
726
+ )
727
+
728
+ return {
729
+ "address": f"{pfn.start_ea:#x}",
730
+ "name": func_name,
731
+ "old_type": old_type,
732
+ "new_type": new_type,
733
+ "status": "retyped",
734
+ }
735
+
736
+
737
+ def _xref_type_name(xref_type: int) -> str:
738
+ """Convert an IDA xref type constant to a human-readable name."""
739
+ import ida_xref
740
+
741
+ _NAMES = {
742
+ ida_xref.fl_CF: "call_far",
743
+ ida_xref.fl_CN: "call_near",
744
+ ida_xref.fl_JF: "jump_far",
745
+ ida_xref.fl_JN: "jump_near",
746
+ ida_xref.fl_F: "ordinary_flow",
747
+ ida_xref.dr_O: "data_offset",
748
+ ida_xref.dr_W: "data_write",
749
+ ida_xref.dr_R: "data_read",
750
+ ida_xref.dr_T: "data_text",
751
+ ida_xref.dr_I: "data_info",
752
+ }
753
+ return _NAMES.get(xref_type, f"unknown_{xref_type}")
754
+
755
+
756
+ @mcp.tool
757
+ def get_xrefs_to(address: str, max_results: int = 100) -> dict:
758
+ """Get cross-references to an address (who references this?). Requires an open database.
759
+
760
+ *address* can be a name (e.g. "main") or hex address (e.g. "0x3f08").
761
+ *max_results* caps the number of xrefs returned (default 100).
762
+
763
+ Returns: ``{"address", "xrefs": [{"from", "type"}], "count", "truncated"}``
764
+ """
765
+ session.require_open()
766
+
767
+ import ida_idaapi
768
+ import idautils
769
+
770
+ ea = _resolve_address(address)
771
+ if ea == ida_idaapi.BADADDR:
772
+ raise ToolError(f"Could not resolve '{address}' to an address.")
773
+
774
+ xrefs = []
775
+ truncated = False
776
+ for xref in idautils.XrefsTo(ea):
777
+ if len(xrefs) >= max_results:
778
+ truncated = True
779
+ break
780
+ xrefs.append({"from": f"{xref.frm:#x}", "type": _xref_type_name(xref.type)})
781
+
782
+ return {
783
+ "address": f"{ea:#x}",
784
+ "xrefs": xrefs,
785
+ "count": len(xrefs),
786
+ "truncated": truncated,
787
+ }
788
+
789
+
790
+ @mcp.tool
791
+ def get_xrefs_from(address: str, max_results: int = 100) -> dict:
792
+ """Get cross-references from an address (what does this reference?). Requires an open database.
793
+
794
+ *address* can be a name (e.g. "main") or hex address (e.g. "0x3f08").
795
+ *max_results* caps the number of xrefs returned (default 100).
796
+
797
+ Returns: ``{"address", "xrefs": [{"to", "type"}], "count", "truncated"}``
798
+ """
799
+ session.require_open()
800
+
801
+ import ida_idaapi
802
+ import idautils
803
+
804
+ ea = _resolve_address(address)
805
+ if ea == ida_idaapi.BADADDR:
806
+ raise ToolError(f"Could not resolve '{address}' to an address.")
807
+
808
+ xrefs = []
809
+ truncated = False
810
+ for xref in idautils.XrefsFrom(ea):
811
+ if len(xrefs) >= max_results:
812
+ truncated = True
813
+ break
814
+ xrefs.append({"to": f"{xref.to:#x}", "type": _xref_type_name(xref.type)})
815
+
816
+ return {
817
+ "address": f"{ea:#x}",
818
+ "xrefs": xrefs,
819
+ "count": len(xrefs),
820
+ "truncated": truncated,
821
+ }
822
+
823
+
824
+ @mcp.tool
825
+ def get_strings(min_length: int = 5, max_results: int = 200, name_filter: str = "") -> dict:
826
+ """List strings found in the database. Requires an open database.
827
+
828
+ *min_length* minimum string length to include (default 5).
829
+ *max_results* caps the number of strings returned (default 200).
830
+ *name_filter* if non-empty, only includes strings containing this
831
+ substring (case-insensitive).
832
+
833
+ Returns: ``{"strings": [{"address", "value", "length", "type"}], "count", "truncated"}``
834
+ """
835
+ session.require_open()
836
+
837
+ import ida_nalt
838
+ import idautils
839
+
840
+ _TYPE_NAMES = {0: "C", 1: "C_16", 2: "C_32", 3: "PASCAL", 4: "PASCAL_16", 5: "LEN2", 6: "LEN4", 7: "LEN2_16"}
841
+
842
+ sc = idautils.Strings()
843
+ sc.setup(
844
+ strtypes=[ida_nalt.STRTYPE_C, ida_nalt.STRTYPE_C_16],
845
+ minlen=min_length,
846
+ )
847
+
848
+ strings = []
849
+ truncated = False
850
+ for s in sc:
851
+ value = str(s)
852
+ if name_filter and name_filter.lower() not in value.lower():
853
+ continue
854
+ if len(strings) >= max_results:
855
+ truncated = True
856
+ break
857
+ strings.append({
858
+ "address": f"{s.ea:#x}",
859
+ "value": value,
860
+ "length": s.length,
861
+ "type": _TYPE_NAMES.get(s.strtype, f"type_{s.strtype}"),
862
+ })
863
+
864
+ return {
865
+ "strings": strings,
866
+ "count": len(strings),
867
+ "truncated": truncated,
868
+ }
869
+
870
+
871
+ @mcp.tool
872
+ def get_imports() -> dict:
873
+ """List all imported functions grouped by module. Requires an open database.
874
+
875
+ Returns: ``{"modules": [{"name", "imports": [{"address", "name", "ordinal"}]}], "total_imports"}``
876
+ """
877
+ session.require_open()
878
+
879
+ import ida_nalt
880
+
881
+ modules = []
882
+ total_imports = 0
883
+
884
+ for i in range(ida_nalt.get_import_module_qty()):
885
+ mod_name = ida_nalt.get_import_module_name(i) or f"module_{i}"
886
+ imports = []
887
+
888
+ def _cb(ea, name, ordinal):
889
+ imports.append({
890
+ "address": f"{ea:#x}",
891
+ "name": name or "",
892
+ "ordinal": ordinal,
893
+ })
894
+ return True # Continue enumeration.
895
+
896
+ ida_nalt.enum_import_names(i, _cb)
897
+ total_imports += len(imports)
898
+ modules.append({"name": mod_name, "imports": imports})
899
+
900
+ return {"modules": modules, "total_imports": total_imports}
901
+
902
+
903
+ @mcp.tool
904
+ def get_exports() -> dict:
905
+ """List all exported functions/symbols. Requires an open database.
906
+
907
+ Returns: ``{"exports": [{"address", "name", "ordinal"}], "count"}``
908
+ """
909
+ session.require_open()
910
+
911
+ import idautils
912
+
913
+ exports = []
914
+ for ordinal, ea, name in idautils.Entries():
915
+ exports.append({
916
+ "address": f"{ea:#x}",
917
+ "name": name or "",
918
+ "ordinal": ordinal,
919
+ })
920
+
921
+ return {"exports": exports, "count": len(exports)}
922
+
923
+
924
+ @mcp.resource("guidelines://standalone_script")
925
+ def standalone_script_guidelines() -> str:
926
+ """Architecture and boilerplate for standalone idalib scripts."""
927
+ return _guidelines.get("standalone_script")
928
+
929
+
930
+ @mcp.resource("guidelines://plugin")
931
+ def plugin_guidelines() -> str:
932
+ """Architecture and boilerplate for IDA plugins (idaapi.plugin_t)."""
933
+ return _guidelines.get("plugin")
934
+
935
+
936
+ @mcp.resource("guidelines://idapython_script")
937
+ def idapython_script_guidelines() -> str:
938
+ """Architecture and boilerplate for IDAPython scripts run inside IDA GUI."""
939
+ return _guidelines.get("idapython_script")
940
+
941
+
942
+ @mcp.prompt
943
+ def reverse_engineer() -> str:
944
+ """Comprehensive workflow for reverse engineering a binary with ida-code.
945
+
946
+ Covers reconnaissance, triage, deep analysis, annotation, and iteration
947
+ using the full set of MCP tools.
948
+ """
949
+ return _prompts.reverse_engineer()
950
+
951
+
952
+ @mcp.prompt
953
+ def create_script(target: str, description: str | None = None) -> str:
954
+ """Coding guidelines and best practices for writing IDAPython scripts.
955
+
956
+ *target* is one of: ``standalone_script``, ``plugin``, ``idapython_script``.
957
+ *description* is an optional description of what the script should do.
958
+ """
959
+ return _prompts.create_script(target, description)
960
+
961
+
962
+ def _parse_host_port(value: str) -> tuple[str, int]:
963
+ """Parse a ``host:port`` string, defaulting to ``127.0.0.1:8080``."""
964
+ if not value:
965
+ return "127.0.0.1", 8080
966
+ # Split on the *last* colon so IPv6 addresses work if quoted.
967
+ if ":" in value:
968
+ host, _, port_str = value.rpartition(":")
969
+ host = host or "127.0.0.1"
970
+ return host, int(port_str)
971
+ # Bare hostname / IP with no port.
972
+ return value, 8080
973
+
974
+
975
+ def main():
976
+ logging.basicConfig(
977
+ level=getattr(logging, LOG_LEVEL, logging.WARNING),
978
+ format="%(asctime)s %(name)s %(levelname)s %(message)s",
979
+ stream=sys.stderr,
980
+ )
981
+
982
+ parser = argparse.ArgumentParser(prog="ida-code", description="MCP server for IDAPython scripting via idalib")
983
+ group = parser.add_mutually_exclusive_group()
984
+ group.add_argument("--http", nargs="?", const="127.0.0.1:8080", metavar="HOST:PORT",
985
+ help="Run with streamable-http transport (default: 127.0.0.1:8080)")
986
+ group.add_argument("--sse", nargs="?", const="127.0.0.1:8080", metavar="HOST:PORT",
987
+ help="Run with SSE transport (default: 127.0.0.1:8080)")
988
+ args = parser.parse_args()
989
+
990
+ if args.http or args.sse:
991
+ transport = "streamable-http" if args.http else "sse"
992
+ host, port = _parse_host_port(args.http or args.sse)
993
+
994
+ auth_token = MCP_AUTH_TOKEN
995
+ if not auth_token:
996
+ auth_token = secrets.token_urlsafe(32)
997
+ print(f"Generated auth token: {auth_token}", file=sys.stderr)
998
+
999
+ from fastmcp.server.auth import DebugTokenVerifier
1000
+
1001
+ mcp.auth = DebugTokenVerifier(
1002
+ validate=lambda token: hmac.compare_digest(token, auth_token),
1003
+ client_id="ida-code-client",
1004
+ )
1005
+ mcp.run(transport=transport, host=host, port=port)
1006
+ else:
1007
+ mcp.run(transport="stdio")
1008
+
1009
+
1010
+ if __name__ == "__main__":
1011
+ main()