aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/client.py
blob: 3e5edb4929603a256a7444d7a867b3072bb57498 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
"""Persistent, thread-safe client for the ida-pro-mcp (idalib) MCP server.

This is the foundation the whole TUI stands on. Unlike the throwaway CLI in the
ida-mcp skill (which re-does the MCP handshake and spins a fresh socket on every
call), this client:

  * Performs the MCP handshake exactly once and keeps the session warm
    (measured: ~7ms/call warm vs ~60ms cold).
  * Reuses TCP connections via a small keep-alive pool (no socket churn over a
    long TUI session) while still allowing concurrent calls from worker threads.
  * Has a precise, *grounded* error taxonomy (see below) so callers can tell
    apart transport failures, protocol errors, hard tool errors, and soft
    per-item "not found" results.
  * Recovers automatically from an expired server session (404 -> re-handshake
    -> retry once) and from transient transport hiccups (bounded retries).
  * Auto-injects the mandatory ``database=<session_id>`` argument, resolving a
    single open session automatically and refusing to guess when several are
    open.

Error taxonomy (verified against the live server, 2026-07):

  IDAConnectionError  transport could not be established / was lost
  IDATimeoutError     a request exceeded its deadline
  IDAProtocolError    malformed / unexpected HTTP or JSON-RPC framing
  IDARPCError         JSON-RPC ``error`` object in the envelope
  IDAToolError        tool returned ``result.isError == true`` (hard failure:
                      bad params, unknown tool, missing database, ...)
  IDASessionError     no session / multiple sessions and none pinned

Note the deliberate *non*-error: a tool that returns ``isError == false`` with an
``error`` field inside its payload (e.g. ``decompile`` on an unknown address, or
per-item failures in a batch tool) is treated as *data*, not an exception. The
caller inspects the payload. Raising on those would break every batch tool.

The client is stdlib-only (``http.client`` / ``urllib.parse``).
"""

from __future__ import annotations

import http.client
import json
import socket
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Any
from urllib.parse import urlsplit

DEFAULT_URL = "http://127.0.0.1:8745/mcp"
PROTOCOL_VERSION = "2025-06-18"

# Tools that must NOT receive an injected ``database`` argument.
SESSION_AGNOSTIC_TOOLS = frozenset({"idb_list", "idb_open", "int_convert"})

# Substrings (lowercased) that identify a stale/invalid IDB session in a tool
# error message. Verified against the live server: after a server restart the
# old session id is gone and every call fails with "Session not found: <id>".
_STALE_SESSION_MARKERS = ("session not found", "database is required")


# --------------------------------------------------------------------------- #
# Exceptions
# --------------------------------------------------------------------------- #
class IDAError(Exception):
    """Base class for all client errors."""


class IDAConnectionError(IDAError):
    """The transport could not be established or was lost."""


class IDATimeoutError(IDAError):
    """A request exceeded its deadline."""


class IDAProtocolError(IDAError):
    """Malformed or unexpected HTTP / JSON-RPC framing."""


class IDARPCError(IDAError):
    """The JSON-RPC envelope carried an ``error`` object."""

    def __init__(self, code: int, message: str, data: Any = None):
        super().__init__(f"JSON-RPC error {code}: {message}")
        self.code = code
        self.message = message
        self.data = data


class IDAToolError(IDAError):
    """A tool call returned ``result.isError == true`` (a hard failure)."""

    def __init__(self, tool: str, message: str):
        super().__init__(f"tool {tool!r} failed: {message}")
        self.tool = tool
        self.message = message


class IDASessionError(IDAError):
    """No IDB session is open, or several are and none was pinned."""


# --------------------------------------------------------------------------- #
# Session model
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class Session:
    session_id: str
    filename: str
    input_path: str
    is_active: bool = False
    is_analyzing: bool = False

    @classmethod
    def from_dict(cls, d: dict) -> "Session":
        return cls(
            session_id=d.get("session_id", ""),
            filename=d.get("filename", ""),
            input_path=d.get("input_path", ""),
            is_active=bool(d.get("is_active", False)),
            is_analyzing=bool(d.get("is_analyzing", False)),
        )


# --------------------------------------------------------------------------- #
# Transport: a tiny keep-alive connection pool over http.client
# --------------------------------------------------------------------------- #
class _Transport:
    """Keep-alive HTTP/1.1 pool. Thread-safe. One request == one pooled conn.

    Connections are checked out, used for exactly one request/response, then
    returned to the pool if still healthy. On a dropped/broken connection the
    request is retried on a fresh connection (bounded by ``max_retries``).
    """

    def __init__(self, url: str, *, max_retries: int = 2, pool_size: int = 8):
        parts = urlsplit(url)
        if parts.scheme not in ("http", "https"):
            raise IDAConnectionError(f"unsupported scheme: {parts.scheme!r}")
        self._https = parts.scheme == "https"
        self._host = parts.hostname or "127.0.0.1"
        self._port = parts.port or (443 if self._https else 80)
        self._path = parts.path or "/"
        self._max_retries = max_retries
        self._pool_size = pool_size
        self._idle: deque[http.client.HTTPConnection] = deque()
        self._lock = threading.Lock()

    def _new_conn(self, timeout: float) -> http.client.HTTPConnection:
        cls = http.client.HTTPSConnection if self._https else http.client.HTTPConnection
        return cls(self._host, self._port, timeout=timeout)

    def _checkout(self, timeout: float) -> http.client.HTTPConnection:
        with self._lock:
            while self._idle:
                conn = self._idle.popleft()
                # Reuse only if the socket still looks alive.
                if conn.sock is not None:
                    conn.timeout = timeout
                    try:
                        conn.sock.settimeout(timeout)
                    except OSError:
                        try:
                            conn.close()
                        except OSError:
                            pass
                        continue
                    return conn
        return self._new_conn(timeout)

    def _checkin(self, conn: http.client.HTTPConnection) -> None:
        with self._lock:
            if len(self._idle) < self._pool_size:
                self._idle.append(conn)
                return
        try:
            conn.close()
        except OSError:
            pass

    def request(
        self, body: bytes, headers: dict[str, str], timeout: float
    ) -> tuple[int, dict[str, str], bytes]:
        """POST ``body``; return (status, response_headers, response_body)."""
        last_exc: Exception | None = None
        for attempt in range(self._max_retries + 1):
            conn = self._checkout(timeout)
            try:
                conn.request("POST", self._path, body=body, headers=headers)
                resp = conn.getresponse()
                status = resp.status
                resp_headers = {k.lower(): v for k, v in resp.getheaders()}
                data = resp.read()  # must fully drain before reuse
            except socket.timeout as e:
                self._discard(conn)
                raise IDATimeoutError(f"request timed out after {timeout}s") from e
            except (
                http.client.RemoteDisconnected,
                http.client.BadStatusLine,
                ConnectionError,
                OSError,
            ) as e:
                # A stale pooled connection, or the server closed on us. Drop it
                # and retry on a fresh connection.
                self._discard(conn)
                last_exc = e
                continue
            else:
                if resp.will_close:
                    self._discard(conn)
                else:
                    self._checkin(conn)
                return status, resp_headers, data
        raise IDAConnectionError(
            f"transport failed after {self._max_retries + 1} attempts: {last_exc}"
        ) from last_exc

    def _discard(self, conn: http.client.HTTPConnection) -> None:
        try:
            conn.close()
        except OSError:
            pass

    def close(self) -> None:
        with self._lock:
            while self._idle:
                self._discard(self._idle.popleft())


# --------------------------------------------------------------------------- #
# The client
# --------------------------------------------------------------------------- #
class IDAClient:
    """A warm, thread-safe handle to one MCP server (and one pinned IDB).

    Typical use::

        with IDAClient(db="4f2223f9") as ida:
            ida.health()
            funcs = ida.call("list_funcs", queries=[{"count": 50}])
            code = ida.call("decompile", addr="main")

    Concurrency: safe to call from multiple threads (Textual workers). Requests
    run on independent pooled connections; only the id counter and handshake
    state are lock-guarded, so calls do not serialize on each other.
    """

    def __init__(
        self,
        url: str = DEFAULT_URL,
        db: str | None = None,
        *,
        timeout: float = 30.0,
        max_retries: int = 2,
        pool_size: int = 8,
        client_name: str = "idatui",
        client_version: str = "0.0.1",
        auto_recover_session: bool = True,
    ):
        self.url = url
        self._db = db
        self.timeout = timeout
        self.auto_recover_session = auto_recover_session
        self._transport = _Transport(url, max_retries=max_retries, pool_size=pool_size)
        self._client_info = {"name": client_name, "version": client_version}

        self._rid = 0
        self._sid: str | None = None
        self._ready = False
        self._state_lock = threading.Lock()  # guards _rid, _sid, _ready
        self._handshake_lock = threading.Lock()  # serializes (re)handshake

    # -- lifecycle --------------------------------------------------------- #
    def __enter__(self) -> "IDAClient":
        self.connect()
        return self

    def __exit__(self, *exc) -> None:
        self.close()

    def connect(self) -> "IDAClient":
        """Ensure the MCP handshake has completed (idempotent, thread-safe)."""
        if self._ready:
            return self
        self._handshake()
        return self

    def close(self) -> None:
        self._transport.close()

    # -- low-level plumbing ------------------------------------------------ #
    def _next_id(self) -> int:
        with self._state_lock:
            self._rid += 1
            return self._rid

    def _headers(self) -> dict[str, str]:
        h = {
            "Content-Type": "application/json",
            "Accept": "application/json, text/event-stream",
        }
        sid = self._sid
        if sid:
            h["Mcp-Session-Id"] = sid
        return h

    def _handshake(self) -> None:
        with self._handshake_lock:
            if self._ready:
                return
            with self._state_lock:
                self._sid = None
            rid = self._next_id()
            status, headers, body = self._transport.request(
                self._encode(
                    {
                        "jsonrpc": "2.0",
                        "id": rid,
                        "method": "initialize",
                        "params": {
                            "protocolVersion": PROTOCOL_VERSION,
                            "capabilities": {},
                            "clientInfo": self._client_info,
                        },
                    }
                ),
                {
                    "Content-Type": "application/json",
                    "Accept": "application/json, text/event-stream",
                },
                self.timeout,
            )
            if status // 100 != 2:
                raise IDAProtocolError(
                    f"initialize failed: HTTP {status}: {body[:200]!r}"
                )
            sid = headers.get("mcp-session-id")
            envelope = self._parse_body(body, rid)
            self._raise_on_rpc_error(envelope)
            with self._state_lock:
                self._sid = sid
            # notifications/initialized is a fire-and-forget notification.
            self._transport.request(
                self._encode({"jsonrpc": "2.0", "method": "notifications/initialized"}),
                self._headers(),
                self.timeout,
            )
            with self._state_lock:
                self._ready = True

    @staticmethod
    def _encode(obj: dict) -> bytes:
        return json.dumps(obj).encode()

    @staticmethod
    def _parse_body(body: bytes, want_id: int) -> dict:
        """Extract the JSON-RPC envelope for ``want_id`` from a (possibly SSE) body."""
        text = body.decode("utf-8", "replace")
        found: dict | None = None
        for line in text.splitlines():
            line = line.strip()
            if line.startswith("data:"):
                line = line[5:].strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except json.JSONDecodeError:
                continue
            if isinstance(obj, dict) and obj.get("id") == want_id:
                found = obj
        if found is None:
            raise IDAProtocolError(
                f"no JSON-RPC response with id={want_id} in body: {text[:200]!r}"
            )
        return found

    @staticmethod
    def _raise_on_rpc_error(envelope: dict) -> None:
        err = envelope.get("error")
        if err:
            raise IDARPCError(
                code=err.get("code", -1),
                message=err.get("message", "unknown"),
                data=err.get("data"),
            )

    def _rpc(self, method: str, params: dict, *, timeout: float | None = None) -> dict:
        """Send a JSON-RPC request, recovering once from an expired session."""
        if not self._ready:
            self._handshake()
        to = self.timeout if timeout is None else timeout
        rid = self._next_id()
        payload = self._encode(
            {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
        )
        status, headers, body = self._transport.request(payload, self._headers(), to)

        if status == 404 and self._sid is not None:
            # Server session expired: re-handshake and retry exactly once.
            with self._state_lock:
                self._ready = False
            self._handshake()
            rid = self._next_id()
            payload = self._encode(
                {"jsonrpc": "2.0", "id": rid, "method": method, "params": params}
            )
            status, headers, body = self._transport.request(
                payload, self._headers(), to
            )

        if status // 100 != 2:
            raise IDAProtocolError(f"HTTP {status}: {body[:200]!r}")

        envelope = self._parse_body(body, rid)
        self._raise_on_rpc_error(envelope)
        return envelope

    # -- tool calls -------------------------------------------------------- #
    @staticmethod
    def _extract_payload(tool: str, result: dict) -> Any:
        """Turn an MCP ``result`` object into a Python payload, or raise.

        Precedence:
          1. ``isError == true``            -> IDAToolError (hard failure)
          2. ``structuredContent`` present  -> return it (already parsed)
          3. ``content[0].text`` is JSON    -> return parsed JSON
          4. otherwise                      -> return the raw text string
        """
        if result.get("isError"):
            text = _first_text(result) or "(no message)"
            raise IDAToolError(tool, text)
        if "structuredContent" in result:
            return result["structuredContent"]
        text = _first_text(result)
        if text is None:
            return result
        try:
            return json.loads(text)
        except (json.JSONDecodeError, TypeError):
            return text

    def _prepare_args(self, tool: str, args: dict) -> tuple[dict, bool]:
        """Return (arguments, db_was_injected). Never mutates the caller's dict."""
        prepared = dict(args)
        if tool in SESSION_AGNOSTIC_TOOLS or "database" in prepared:
            return prepared, False
        prepared["database"] = self.resolve_db()
        return prepared, True

    def call_envelope(self, tool: str, *, timeout: float | None = None, **args) -> dict:
        """Call ``tool`` and return the full JSON-RPC envelope (for debugging)."""
        prepared, _ = self._prepare_args(tool, args)
        return self._rpc(
            "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout
        )

    def call(self, tool: str, *, timeout: float | None = None, **args) -> Any:
        """Call ``tool`` and return its payload (parsed JSON when possible).

        Raises IDAToolError on a hard tool failure. Soft/per-item errors (an
        ``error`` field with ``isError == false``) are returned as data.

        If ``auto_recover_session`` is set and the db was auto-injected, a stale
        "Session not found" error (e.g. after a server restart) triggers exactly
        one transparent recovery: drop the stale pin, re-resolve the session,
        and retry. A db the caller pinned explicitly is never silently switched.
        """
        prepared, injected = self._prepare_args(tool, args)
        envelope = self._rpc(
            "tools/call", {"name": tool, "arguments": prepared}, timeout=timeout
        )
        try:
            return self._extract_payload(tool, envelope.get("result", {}))
        except IDAToolError as e:
            if not (injected and self.auto_recover_session and _is_stale_session(e)):
                raise
            # The pinned IDB session vanished (server restart). Re-resolve the
            # sole session and retry once. resolve_db() raises IDASessionError
            # if zero/many sessions exist, so we never guess.
            self.set_db(None)
            prepared2, _ = self._prepare_args(tool, args)
            envelope2 = self._rpc(
                "tools/call", {"name": tool, "arguments": prepared2}, timeout=timeout
            )
            return self._extract_payload(tool, envelope2.get("result", {}))

    # -- session management ------------------------------------------------ #
    def list_sessions(self) -> list[Session]:
        result = self._rpc("tools/call", {"name": "idb_list", "arguments": {}})
        payload = self._extract_payload("idb_list", result.get("result", {}))
        sessions = payload.get("sessions", []) if isinstance(payload, dict) else []
        return [Session.from_dict(s) for s in sessions]

    def resolve_db(self) -> str:
        """Return the pinned session id, auto-resolving a lone open session.

        Raises IDASessionError if none is open, or if several are open and none
        has been pinned via ``db=`` / :meth:`set_db`.
        """
        if self._db:
            return self._db
        sessions = self.list_sessions()
        usable = [s for s in sessions if s.session_id]
        if len(usable) == 1:
            self._db = usable[0].session_id
            return self._db
        if not usable:
            raise IDASessionError(
                "no open IDB session with a usable id; open one with idb_open"
            )
        opts = ", ".join(f"{s.session_id} ({s.filename})" for s in usable)
        raise IDASessionError(
            f"multiple sessions open; pin one with db=... . Options: {opts}"
        )

    def set_db(self, db: str | None) -> None:
        self._db = db

    @property
    def db(self) -> str | None:
        return self._db

    def _session_input_path(self, db: str) -> str:
        for s in self.list_sessions():
            if s.session_id == db:
                if not s.input_path:
                    raise IDASessionError(f"session {db} has no input_path to re-open")
                return s.input_path
        raise IDASessionError(f"session {db} not found")

    def bump_idle_ttl(self, idle_ttl_sec: int = 1_000_000_000,
                      path: str | None = None) -> None:
        """Raise the worker's idle self-exit TTL so an interactive session never
        gets reaped while the user is just reading.

        Headless idalib workers self-exit after ``idle_ttl_sec`` (default 600s)
        with no requests. ``idb_open`` on the already-open path is idempotent
        (returns the same session) and re-applies the TTL, so this simply opens
        the pinned session's path with a huge TTL. The default (~31 years) is
        effectively 'never'.
        """
        p = path or self._session_input_path(self.resolve_db())
        self.call("idb_open", input_path=p, idle_ttl_sec=int(idle_ttl_sec))

    def keepalive(self, interval: float = 120.0) -> "KeepAlive":
        """Return a (not-yet-started) heartbeat that touches the worker's idle
        watchdog every ``interval`` seconds. Belt-and-suspenders next to
        :meth:`bump_idle_ttl`; also covers adopted sessions we didn't open.
        """
        return KeepAlive(self, interval=interval)

    # -- convenience ------------------------------------------------------- #
    def health(self) -> dict:
        return self.call("server_health")

    def list_tools(self) -> list[tuple[str, str]]:
        envelope = self._rpc("tools/list", {})
        out = []
        for t in envelope.get("result", {}).get("tools", []):
            desc = (t.get("description") or "").splitlines()
            out.append((t["name"], desc[0] if desc else ""))
        return out

    def schema(self, tool: str) -> dict:
        envelope = self._rpc("tools/list", {})
        for t in envelope.get("result", {}).get("tools", []):
            if t["name"] == tool:
                return t.get("inputSchema", {})
        raise IDAError(f"tool not found: {tool}")


class KeepAlive:
    """Background heartbeat that keeps an idalib worker from idling out.

    Any forwarded request resets the worker's idle timer, so a periodic cheap
    ``server_health`` is enough. Failures are swallowed (the next real call will
    auto-recover); the heartbeat just keeps a chilling TUI's worker alive.
    """

    def __init__(self, client: "IDAClient", interval: float = 120.0):
        if interval <= 0:
            raise ValueError("interval must be > 0")
        self._client = client
        self.interval = interval
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self.beats = 0
        self.failures = 0

    def start(self) -> "KeepAlive":
        if self._thread is not None:
            return self
        self._stop.clear()
        self._thread = threading.Thread(
            target=self._run, daemon=True, name="idatui-keepalive"
        )
        self._thread.start()
        return self

    def stop(self) -> None:
        self._stop.set()
        t = self._thread
        self._thread = None
        if t is not None:
            t.join(timeout=2.0)

    def __enter__(self) -> "KeepAlive":
        return self.start()

    def __exit__(self, *exc) -> None:
        self.stop()

    def _run(self) -> None:
        while not self._stop.wait(self.interval):
            try:
                self._client.health()
                self.beats += 1
            except IDAError:
                self.failures += 1


def _is_stale_session(e: IDAToolError) -> bool:
    msg = e.message.lower()
    return any(marker in msg for marker in _STALE_SESSION_MARKERS)


def _first_text(result: dict) -> str | None:
    content = result.get("content")
    if isinstance(content, list):
        for item in content:
            if isinstance(item, dict) and item.get("type") == "text":
                return item.get("text")
    return None


# --------------------------------------------------------------------------- #
# Tiny self-check CLI:  python -m idatui.client [--db ID] [--url URL] [health]
# --------------------------------------------------------------------------- #
def _main(argv: list[str]) -> int:
    import os

    url = DEFAULT_URL
    db = os.environ.get("IDA_MCP_DB")
    rest: list[str] = []
    it = iter(argv)
    for a in it:
        if a == "--url":
            url = next(it)
        elif a == "--db":
            db = next(it)
        else:
            rest.append(a)

    ida = IDAClient(url, db=db)
    try:
        ida.connect()
        t0 = time.time()
        sessions = ida.list_sessions()
        print(f"sessions ({(time.time() - t0) * 1e3:.1f}ms):")
        for s in sessions:
            mark = "*" if s.is_active else " "
            print(f"  {mark} {s.session_id or '<none>':10} {s.filename}")
        try:
            h = ida.health()
            print("health:", json.dumps(h, indent=1)[:400])
        except IDASessionError as e:
            print(f"health: skipped ({e})")
    finally:
        ida.close()
    return 0


if __name__ == "__main__":
    import sys

    raise SystemExit(_main(sys.argv[1:]))