diff --git a/plugin/__init__.py b/plugin/__init__.py index 2fd7b33f..b451ed2d 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -58,7 +58,6 @@ def start_server(self, bv): self.server.binary_ops.register_view(bv) except Exception: pass - _show_popup("MCP Server", "Server is already running.") return self.server.binary_ops.current_view = bv try: @@ -68,14 +67,7 @@ def start_server(self, bv): self.server.start() global _mcp_user_stopped _mcp_user_stopped = False - bn.log_info( - f"MCP server started successfully on http://{self.config.server.host}:{self.config.server.port}" - ) _set_status_indicator(True) - _show_popup( - "MCP Server Started", - f"Running at http://{self.config.server.host}:{self.config.server.port}", - ) except Exception as e: bn.log_error(f"Failed to start MCP server: {e!s}") _show_popup("MCP Server Error", f"Failed to start: {e}") @@ -133,6 +125,9 @@ def _try_autostart_for_bv(bv): if _mcp_user_stopped: bn.log_debug("MCP Max autostart suppressed due to manual stop") return + if _is_server_running(): + plugin.server.binary_ops.current_view = bv + return plugin.start_server(bv) except Exception as e: bn.log_error(f"MCP Max autostart failed: {e}") @@ -603,7 +598,7 @@ class _MCPMaxUINotification(ui.UIContextNotification): def __init__(self): super().__init__() ui.UIContext.registerNotification(self) - + def _get_active_bv(self): try: ctx = ui.UIContext.activeContext() diff --git a/plugin/core/binary_operations.py b/plugin/core/binary_operations.py index 1ac91e18..76c49999 100644 --- a/plugin/core/binary_operations.py +++ b/plugin/core/binary_operations.py @@ -27,16 +27,29 @@ def current_view(self) -> bn.BinaryView | None: @current_view.setter def current_view(self, bv: bn.BinaryView | None): - self._current_view = bv + if bv is self._current_view: + return if bv: - bn.log_info(f"Set current binary view: {bv.file.filename}") + previous_filename = self._view_filename(self._current_view) try: self._register_view(bv) except Exception: pass + self._current_view = bv + current_filename = self._view_filename(bv) + if current_filename != previous_filename: + bn.log_info(f"Set current binary view: {current_filename}") else: + self._current_view = None bn.log_info("Cleared current binary view") + @staticmethod + def _view_filename(bv: bn.BinaryView | None) -> str | None: + try: + return str(bv.file.filename) if bv and bv.file else None + except Exception: + return None + def load_binary(self, filepath: str) -> bn.BinaryView: """Load a binary file using the appropriate method based on the Binary Ninja API version""" try: @@ -220,7 +233,7 @@ def list_open_binaries(self) -> list[dict[str, str]]: vb_canon = vb entries.append((canonical_id, fn, bool(vb_canon is self._current_view))) # Sort by filename for stable ordering - entries.sort(key=lambda t: (t[1] or "")) + entries.sort(key=lambda t: t[1] or "") for cid, fn, active in entries: items.append({"id": cid, "filename": fn, "active": active}) return items diff --git a/plugin/server/http_server.py b/plugin/server/http_server.py index 8a2df226..a5f4e6ad 100644 --- a/plugin/server/http_server.py +++ b/plugin/server/http_server.py @@ -2371,7 +2371,9 @@ def start(self): self.thread = threading.Thread(target=self.server.serve_forever) self.thread.daemon = True self.thread.start() - bn.log_info(f"Server started on {self.config.server.host}:{self.config.server.port}") + bn.log_info( + f"MCP server started on http://{self.config.server.host}:{self.config.server.port}" + ) def stop(self): """Stop the HTTP server and clean up resources.""" diff --git a/tests/test_binary_operations.py b/tests/test_binary_operations.py new file mode 100644 index 00000000..98c6bec2 --- /dev/null +++ b/tests/test_binary_operations.py @@ -0,0 +1,86 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +def _load_binary_operations_module(): + info_logs = [] + binaryninja = types.ModuleType("binaryninja") + binaryninja.log_info = info_logs.append + binaryninja.log_warn = lambda _message: None + binaryninja.log_error = lambda _message: None + + def binaryninja_type(name): + value = type(name, (), {}) + setattr(binaryninja, name, value) + return value + + binaryninja.__getattr__ = binaryninja_type + sys.modules["binaryninja"] = binaryninja + + enums = types.ModuleType("binaryninja.enums") + enums.StructureVariant = type("StructureVariant", (), {}) + enums.TypeClass = type("TypeClass", (), {}) + sys.modules[enums.__name__] = enums + + for package in ("plugin", "plugin.core", "plugin.utils"): + module = types.ModuleType(package) + module.__path__ = [] + sys.modules[package] = module + + config = types.ModuleType("plugin.core.config") + config.BinaryNinjaConfig = object + sys.modules[config.__name__] = config + + string_utils = types.ModuleType("plugin.utils.string_utils") + string_utils.escape_non_ascii = lambda value: value + sys.modules[string_utils.__name__] = string_utils + + path = Path(__file__).parents[1] / "plugin" / "core" / "binary_operations.py" + spec = importlib.util.spec_from_file_location("plugin.core.binary_operations", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module, info_logs + + +class FakeView: + def __init__(self, filename): + self.file = types.SimpleNamespace(filename=filename) + + +class BinaryOperationsTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module, cls.info_logs = _load_binary_operations_module() + + def setUp(self): + self.info_logs.clear() + self.operations = self.module.BinaryOperations(object()) + + def test_first_assignment_remains_active_and_repeat_is_noop(self): + view = FakeView("sample.bndb") + + self.operations.current_view = view + self.operations.current_view = view + + self.assertIs(self.operations.current_view, view) + self.assertEqual(len(self.operations._views_by_id), 1) + self.assertEqual(self.info_logs, ["Set current binary view: sample.bndb"]) + + def test_replacement_view_for_same_file_updates_silently(self): + first_view = FakeView("sample.bndb") + replacement_view = FakeView("sample.bndb") + + self.operations.current_view = first_view + self.operations.current_view = replacement_view + + self.assertIs(self.operations.current_view, replacement_view) + self.assertEqual(len(self.operations._views_by_id), 1) + self.assertEqual(self.info_logs, ["Set current binary view: sample.bndb"]) + + +if __name__ == "__main__": + unittest.main()