diff --git a/binaryninjacore.h b/binaryninjacore.h index fe405a4926..32dd66e7bc 100644 --- a/binaryninjacore.h +++ b/binaryninjacore.h @@ -37,14 +37,14 @@ // Current ABI version for linking to the core. This is incremented any time // there are changes to the API that affect linking, including new functions, // new types, or modifications to existing functions or types. -#define BN_CURRENT_CORE_ABI_VERSION 192 +#define BN_CURRENT_CORE_ABI_VERSION 193 // Minimum ABI version that is supported for loading of plugins. Plugins that // are linked to an ABI version less than this will not be able to load and // will require rebuilding. The minimum version is increased when there are // incompatible changes that break binary compatibility, such as changes to // existing types or functions. -#define BN_MINIMUM_CORE_ABI_VERSION 192 +#define BN_MINIMUM_CORE_ABI_VERSION 193 #define BN_DEMANGLER_MSVC "MS" #define BN_DEMANGLER_GNU3 "GNU3" @@ -348,6 +348,7 @@ extern "C" typedef struct BNUndoEntry BNUndoEntry; typedef struct BNDemangler BNDemangler; typedef struct BNMcpTool BNMcpTool; + typedef struct BNMcpToolGroup BNMcpToolGroup; typedef struct BNMcpToolCall BNMcpToolCall; typedef struct BNMcpToolResult BNMcpToolResult; typedef struct BNFirmwareNinja BNFirmwareNinja; @@ -4109,8 +4110,17 @@ extern "C" const char* outputSchema; BNMcpToolScope scope; uint32_t annotations; + BNMcpToolGroup* group; } BNMcpToolDefinition; + typedef struct BNMcpToolGroupDefinition + { + const char* name; + const char* title; + const char* description; + bool enabledByDefault; + } BNMcpToolGroupDefinition; + typedef struct BNMcpToolCallbacks { void* context; @@ -8954,6 +8964,18 @@ extern "C" // MCP tools BINARYNINJACOREAPI BNMcpTool* BNRegisterMcpTool( const BNMcpToolDefinition* definition, const BNMcpToolCallbacks* callbacks); + BINARYNINJACOREAPI BNMcpToolGroup* BNRegisterMcpToolGroup(const BNMcpToolGroupDefinition* definition); + BINARYNINJACOREAPI BNMcpToolGroup* BNNewMcpToolGroupReference(BNMcpToolGroup* group); + BINARYNINJACOREAPI void BNFreeMcpToolGroup(BNMcpToolGroup* group); + BINARYNINJACOREAPI BNMcpToolGroup** BNGetMcpToolGroupList(size_t* count); + BINARYNINJACOREAPI void BNFreeMcpToolGroupList(BNMcpToolGroup** groups, size_t count); + BINARYNINJACOREAPI BNMcpToolGroup* BNGetMcpToolGroupByName(const char* name); + BINARYNINJACOREAPI char* BNGetMcpToolGroupName(BNMcpToolGroup* group); + BINARYNINJACOREAPI char* BNGetMcpToolGroupTitle(BNMcpToolGroup* group); + BINARYNINJACOREAPI char* BNGetMcpToolGroupDescription(BNMcpToolGroup* group); + BINARYNINJACOREAPI bool BNIsMcpToolGroupEnabledByDefault(BNMcpToolGroup* group); + BINARYNINJACOREAPI bool BNIsMcpToolGroupEnabled(BNMcpToolGroup* group); + BINARYNINJACOREAPI BNMcpToolGroup* BNGetMcpToolGroup(BNMcpTool* tool); BINARYNINJACOREAPI BNMcpTool* BNNewMcpToolReference(BNMcpTool* tool); BINARYNINJACOREAPI void BNFreeMcpTool(BNMcpTool* tool); BINARYNINJACOREAPI BNMcpTool** BNGetMcpToolList(size_t* count); diff --git a/docs/dev/mcp-tools.md b/docs/dev/mcp-tools.md index ae75f5201b..14d9fc03a9 100644 --- a/docs/dev/mcp-tools.md +++ b/docs/dev/mcp-tools.md @@ -7,7 +7,7 @@ Plugins can add tools to Binary Ninja's [MCP server](../guide/mcp.md). Both the A tool has a name, a description, a JSON Schema describing its input, and a handler that receives the arguments and returns a result. - **Names** must be 1 to 64 letters, digits, `_` or `-`, and unique in the process. Prefix yours with your plugin's name, such as `myplugin_find_crypto`. The `bn_` prefix is used by Binary Ninja's built-in tools. -- **Scope.** A BinaryView-scoped tool, the default, runs against the MCP session's active BinaryView. The server resolves that view before the handler runs and reports `no_active_binary_view` itself when there is none, so the handler can rely on having one. A global tool runs without a view, and can still ask for one if the session has it. +- **Scope.** A BinaryView-scoped tool, the default, requires a `binary` handle on each call. The server resolves that view before the handler runs and reports `binary_not_found` when it cannot, so the handler can rely on having one. A global tool runs without a view. - **Annotations** mark a tool as read-only, destructive, idempotent or open-world. Clients use them to decide how to present a tool and whether to ask the user before running it. Every hint is sent, so a tool without a flag is advertised as one that may modify its environment, is not destructive, is not idempotent, and does not reach outside Binary Ninja. - **Results** are text, structured content (a JSON object), or both. When a tool returns structured content and no text, clients that ignore structured content see the JSON as text. An error result carries a machine-readable `errorCode` and an `errorMessage` as structured content. A tool with an output schema returns them as text instead, since the error does not match the schema. - **Return one representation.** Some MCP clients show the model only the structured content, and others only the text. A tool that returns both must put everything the model needs in each, so prefer returning one or the other. @@ -33,7 +33,7 @@ def myplugin_find_strings( min_length: int = 4, section: Optional[str] = None, ) -> dict: - """Find strings in the active binary view. + """Find strings in the selected binary view. :param min_length: Minimum string length in bytes. :param section: Only search this section. @@ -86,7 +86,7 @@ using namespace BinaryNinja::MCP; extern "C" BINARYNINJAPLUGIN bool CorePluginInit() { - MakeTool({"myplugin_set_comment", "Set Comment", "Set the comment at an address in the active binary view.", + MakeTool({"myplugin_set_comment", "Set Comment", "Set the comment at an address in the selected binary view.", Scope::BinaryView, IdempotentHint}) .Param(Address("address", "Address expression of the comment.")) .Param(String("text", "Comment text.")) @@ -136,7 +136,7 @@ struct FunctionCount; impl McpTool for FunctionCount { fn definition(&self) -> McpToolDefinition { - McpToolInfo::new("myplugin_function_count", "Count the functions in the active binary view.") + McpToolInfo::new("myplugin_function_count", "Count the functions in the selected binary view.") .with_annotations(McpToolAnnotations::READ_ONLY) .with_input_schema(empty_input_schema()) } @@ -171,7 +171,7 @@ impl TypedMcpTool for SetComment { type Args = CommentArgs; fn info(&self) -> McpToolInfo { - McpToolInfo::new("myplugin_set_comment", "Set the comment at an address in the active binary view.") + McpToolInfo::new("myplugin_set_comment", "Set the comment at an address in the selected binary view.") } fn invoke(&self, call: &McpToolCall, args: CommentArgs) -> Result { diff --git a/docs/guide/mcp.md b/docs/guide/mcp.md index 3c680419db..d7039eedb7 100644 --- a/docs/guide/mcp.md +++ b/docs/guide/mcp.md @@ -1,6 +1,6 @@ # MCP Server -Binary Ninja can expose analysis data to MCP clients through the Model Context Protocol (MCP). This allows an AI assistant or other MCP client to inspect open files, open binaries, run analysis updates, and query read-only BinaryView information such as functions, symbols, strings, sections, disassembly, IL, and decompiled text. +Binary Ninja can expose analysis data to MCP clients through the Model Context Protocol (MCP). This allows an AI assistant or other MCP client to open binaries, run analysis updates, inspect functions, symbols, strings, sections, disassembly, IL, and decompiled text, and edit analysis data such as comments, names, and types. Binary Ninja provides two MCP server variants: @@ -13,17 +13,20 @@ Binary Ninja provides two MCP server variants: The exact tool list may change as the MCP server develops, but both server variants expose tools in the following broad categories: -- **File and view management**: list open files and databases, open a file or database, close or save an open item, list BinaryViews, and select the active BinaryView. +- **Binary and project management**: list, open, save, and close binaries and databases, list and open projects, and open files within projects. - **Analysis control**: inspect analysis status, start an analysis update, run analysis and wait for completion, or abort active analysis work. -- **Binary overview**: request a compact triage summary of the active BinaryView, including entry point, segment, section, symbol, function, string, and data variable counts. +- **Binary overview**: request a compact triage summary of a BinaryView, including entry point, segment, section, symbol, function, string, and data variable counts. - **Program structure**: list entry points, segments, sections, symbols, imports, exports, relocations, data variables, and strings. -- **Memory inspection**: read bytes from the active BinaryView and receive the result as hex and base64. +- **Memory inspection**: read bytes from a BinaryView and receive the result as hex and base64. - **Function inspection**: list and search functions, request function metadata, render disassembly, render decompiled Pseudo C, render IL, inspect basic blocks, callers, callees, cross-references, stack layout, and complexity metrics. +- **Analysis editing**: manage comments, symbols, variables, function prototypes, types, data variables, and sections. Use your MCP client's tool listing UI or command to see the complete set of tools available in your installed Binary Ninja version. Plugins can add their own tools, which both server variants offer alongside the built-in ones. See [Writing MCP Tools](../dev/mcp-tools.md). +Tool groups are controlled by boolean settings under `mcp.groups`. Built-in groups are enabled by default. Disabling a group hides its tools and rejects calls to them. Reconnect your MCP client after changing these settings so it loads the updated tool list. + ## Tool Calling Conventions The MCP server exposes Binary Ninja state through a small set of identifiers and conventions. These are worth understanding because they differ from many REST APIs. @@ -32,37 +35,37 @@ The MCP server exposes Binary Ninja state through a small set of identifiers and Open files, projects, databases, and BinaryViews are identified by opaque handles returned from MCP tools: -- `openItem` handles identify files, databases, or projects. Tools such as `bn_open_item_save` and `bn_open_item_close` use these handles. -- `binaryView` handles identify BinaryViews for an open item. Use `bn_binary_view_list` to discover them and `bn_binary_view_set_active` to select one. +- `binary` handles identify BinaryViews for open files and databases. Get them from `bn_binary_open` or `bn_binary_list`. +- `project` handles identify open projects. Get them from `bn_project_open` or `bn_project_list`. -Do not construct handles yourself. Treat them as session-scoped values returned by the server. +Do not construct handles yourself. They remain valid while the corresponding binary or project is open in the server process. -Some BinaryViews may be listed as available but not yet created. Selecting one with `bn_binary_view_set_active` may create that view. When multiple BinaryViews are available for a file, use the `recommended` field from `bn_binary_view_list` or choose the view type you need before running read-only analysis tools. +The `viewTypes` metadata lists available view types for a file. Pass `viewType` to `bn_binary_open` to request a particular view type. -### Active BinaryView +### Target BinaryView -Most read-only tools operate on the active BinaryView rather than taking a `binaryView` parameter every time. If a tool returns `no_active_binary_view`, first call `bn_binary_view_list`, choose a view, and call `bn_binary_view_set_active`. +Every BinaryView-scoped tool requires a `binary` handle on each call. If a tool returns `binary_not_found`, call `bn_binary_list` and supply a valid handle. -In the GUI server, the active BinaryView follows the Binary Ninja UI unless an MCP client explicitly changes it. In the headless server, the active BinaryView is managed entirely through MCP tools. +Both server variants use this per-call target. In the GUI, the target can be any open binary, regardless of which tab has focus. ### Address Expressions -Address parameters are strings, not JSON numbers. They are parsed as Binary Ninja expressions in the active BinaryView, so clients can use plain addresses, symbols, and expressions supported by Binary Ninja. +Address parameters are strings, not JSON numbers. They are parsed as Binary Ninja expressions in the selected BinaryView, so clients can use plain addresses, symbols, and expressions supported by Binary Ninja. -Binary-view-domain numeric parameters, such as byte counts for memory reads and ranges, accept either JSON integers or strings when the tool input schema advertises them. JSON integers are consumed directly. String values are parsed as Binary Ninja expressions in the active BinaryView. Binary Ninja expression syntax applies to string values; use `0n` for decimal strings when needed, for example `0n64`. +Binary-view-domain numeric parameters, such as byte counts for memory reads and ranges, accept either JSON integers or strings when the tool input schema advertises them. JSON integers are consumed directly. String values are parsed as Binary Ninja expressions in the selected BinaryView. Binary Ninja expression syntax applies to string values; use `0n` for decimal strings when needed, for example `0n64`. Examples: ```json -{"address": "0x401000", "length": 64} +{"binary": "", "address": "0x401000", "length": 64} ``` ```json -{"function": "main"} +{"binary": "", "function": "main"} ``` ```json -{"start": ".text", "length": "0n256"} +{"binary": "", "start": ".text", "length": "0n256"} ``` Use address expressions for fields named `address`, `start`, `end`, `function`, `symbol`, `comment`, and `datavar`. Use JSON integers or expression strings for byte-span input parameters such as `length` when the tool input schema advertises them. A `length` output column is returned metadata and does not imply that the tool accepts a `length` input. Metadata parameters such as pagination `offset` and `limit`, symbol `ordinal`, and collection caps such as `maxItems` are JSON integers only. @@ -70,13 +73,13 @@ Use address expressions for fields named `address`, `start`, `end`, `function`, Function tools identify a function by its start address expression: ```json -{"function": "0x401000"} +{"binary": "", "function": "0x401000"} ``` If more than one function has the same start address, pass `arch` with the architecture name returned by function-listing tools: ```json -{"function": "0x401000", "arch": "x86_64"} +{"binary": "", "function": "0x401000", "arch": "x86_64"} ``` ### Ranges, Queries, and Pagination @@ -99,21 +102,21 @@ Addresses in tool output are formatted as hexadecimal strings so clients do not ## GUI Server -The GUI MCP server is hosted inside the Binary Ninja application. It uses HTTP only and must be both enabled and started before a client can connect. Some MCP clients refer to this as an HTTP or streamable HTTP server. +The GUI MCP server is hosted inside the Binary Ninja application. It uses HTTP only and must be started before a client can connect. Some MCP clients refer to this as an HTTP or streamable HTTP server. On Windows, use this built-in HTTP server for native MCP clients. Binary Ninja does not yet ship a standalone `binaryninja_mcp.exe` in Windows packages. -### Enable and Start +### Start the Server 1. Open Settings with `[CMD/CTRL] ,`. -2. Enable `ui.mcp.enabled`. This setting requires a restart. -3. Optionally configure the HTTP settings: +2. Optionally configure the HTTP settings: - `ui.mcp.port`: HTTP port. Default: `24642`; use `0` for an OS-assigned port - `ui.mcp.endpoint`: HTTP endpoint path. Default: `/mcp` - `ui.mcp.token`: Optional bearer token. Leave blank to disable HTTP authorization -4. Restart Binary Ninja. -5. Start the server with `Plugins > MCP > Start Server`. -6. Use `Plugins > MCP > Copy Connection Info` to copy the exact URL and authorization header for your current session. +3. Start the server with `Plugins > MCP > Start Server`. +4. Use `Plugins > MCP > Copy Connection Info` to copy the exact URL and authorization header for your current session. + +Enable `ui.mcp.enabled` to start the server automatically the next time Binary Ninja launches. Use `Plugins > MCP > Stop Server` to stop the server. @@ -143,7 +146,7 @@ If `ui.mcp.port` is `0`, the operating system chooses a port when the server sta The standalone `binaryninja_mcp` server is for headless operation and uses stdio only. Configure your MCP client to launch `binaryninja_mcp` as a local command-line MCP server. -The headless server opens and analyzes files without the GUI and exposes the same file manager and read-only BinaryView inspection tools as the GUI server. +The headless server opens and analyzes files without the GUI and exposes the same built-in tools as the GUI server. Native Windows packages do not yet include `binaryninja_mcp.exe`. On Windows, connect to the built-in GUI MCP HTTP server instead. If you require the headless stdio server, install and run the Linux build of Binary Ninja under WSL and use the Linux `binaryninja_mcp` executable from that environment. @@ -267,7 +270,6 @@ Set `BN_MCP_TOKEN` to the value of `ui.mcp.token`. Omit `bearer_token_env_var` i If an MCP client cannot connect to the GUI server: -- Confirm `ui.mcp.enabled` is enabled. - Confirm the server has been started with `Plugins > MCP > Start Server`. - Use `Plugins > MCP > Copy Connection Info` and copy the exact URL and token header into the client configuration. - If you changed `ui.mcp.port` or `ui.mcp.endpoint`, restart the GUI MCP server. diff --git a/mcp.cpp b/mcp.cpp index 85eb2de4d9..2e8c1925dd 100644 --- a/mcp.cpp +++ b/mcp.cpp @@ -104,6 +104,7 @@ Ref CreateToolUsing(const ToolDefinition& definition, const std::string& i apiDefinition.outputSchema = definition.outputSchema.empty() ? nullptr : definition.outputSchema.c_str(); apiDefinition.scope = static_cast(definition.scope); apiDefinition.annotations = definition.annotations; + apiDefinition.group = definition.group ? definition.group->GetObject() : nullptr; auto* context = new ToolHandler(std::move(handler)); BNMcpToolCallbacks callbacks = {}; @@ -252,6 +253,9 @@ std::vector> Tool::GetList() Ref Tool::GetByName(const std::string& name) { + if (name.find('\0') != std::string::npos) + return nullptr; + BNMcpTool* tool = BNGetMcpToolByName(name.c_str()); return tool ? new Tool(tool) : nullptr; } @@ -856,3 +860,74 @@ ArgumentResult JsonValue::Convert( { return &value; } + + +Ref Tool::GetGroup() const +{ + return new ToolGroup(BNGetMcpToolGroup(m_object)); +} + + +ToolGroup::ToolGroup(BNMcpToolGroup* group) +{ + m_object = group; +} + + +Ref ToolGroup::Register(const GroupDefinition& definition) +{ + BNMcpToolGroupDefinition group = { + definition.name.c_str(), definition.title.c_str(), definition.description.c_str(), definition.enabledByDefault}; + BNMcpToolGroup* handle = BNRegisterMcpToolGroup(&group); + return handle ? new ToolGroup(handle) : nullptr; +} + + +std::vector> ToolGroup::GetList() +{ + size_t count = 0; + BNMcpToolGroup** groups = BNGetMcpToolGroupList(&count); + std::vector> result; + result.reserve(count); + for (size_t i = 0; i < count; i++) + result.push_back(new ToolGroup(BNNewMcpToolGroupReference(groups[i]))); + BNFreeMcpToolGroupList(groups, count); + return result; +} + + +Ref ToolGroup::GetByName(const std::string& name) +{ + BNMcpToolGroup* group = BNGetMcpToolGroupByName(name.c_str()); + return group ? new ToolGroup(group) : nullptr; +} + + +std::string ToolGroup::GetName() const +{ + return TakeString(BNGetMcpToolGroupName(m_object)); +} + + +std::string ToolGroup::GetTitle() const +{ + return TakeString(BNGetMcpToolGroupTitle(m_object)); +} + + +std::string ToolGroup::GetDescription() const +{ + return TakeString(BNGetMcpToolGroupDescription(m_object)); +} + + +bool ToolGroup::IsEnabledByDefault() const +{ + return BNIsMcpToolGroupEnabledByDefault(m_object); +} + + +bool ToolGroup::IsEnabled() const +{ + return BNIsMcpToolGroupEnabled(m_object); +} diff --git a/mcp.h b/mcp.h index f76d4d26ee..eef3a325b6 100644 --- a/mcp.h +++ b/mcp.h @@ -48,6 +48,30 @@ enum class Scope : uint8_t BinaryView = BinaryViewScope, }; +struct GroupDefinition +{ + std::string name; + std::string title; + std::string description; + bool enabledByDefault = true; +}; + +class ToolGroup: public CoreRefCountObject +{ +public: + explicit ToolGroup(BNMcpToolGroup* group); + + static Ref Register(const GroupDefinition& definition); + static std::vector> GetList(); + static Ref GetByName(const std::string& name); + + std::string GetName() const; + std::string GetTitle() const; + std::string GetDescription() const; + bool IsEnabledByDefault() const; + bool IsEnabled() const; +}; + struct ToolDefinition { std::string name; @@ -58,6 +82,7 @@ struct ToolDefinition uint32_t annotations = 0; /*! Empty for no output schema. */ std::string outputSchema; + Ref group; }; /*! A converted argument, or the message explaining why the argument is invalid. */ @@ -130,6 +155,7 @@ class Tool: public CoreRefCountObject GetGroup() const; std::string GetInputSchema() const; /*! Empty when the tool has no output schema. */ std::string GetOutputSchema() const; @@ -951,6 +977,14 @@ class ToolBuilder return std::forward(transform)(std::move(*this)); } + ToolBuilder InGroup(Ref group) && + { + if (!group) + throw std::invalid_argument("MCP tool group must not be null"); + m_definition.group = std::move(group); + return std::move(*this); + } + ToolBuilder OutputSchema(std::string schema) && { m_definition.outputSchema = std::move(schema); diff --git a/mcpserver.h b/mcpserver.h index b12e297afb..bd03039190 100644 --- a/mcpserver.h +++ b/mcpserver.h @@ -40,7 +40,7 @@ class ToolCallHost }; /*! Creates a tool without registering it. This can be used to expose a tool that is specific to - one MCP server, such as one that manages that server's active binary view. Returns null when the + one MCP server, such as one that opens and lists binaries. Returns null when the definition is invalid. */ Ref CreateTool(ToolSpec spec); diff --git a/python/mcp.py b/python/mcp.py index 8a0021acc7..9324903fbe 100644 --- a/python/mcp.py +++ b/python/mcp.py @@ -28,7 +28,7 @@ @mcp.tool(read_only=True) def myplugin_function_count(call: mcp.ToolCall, min_size: int = 0) -> dict: - \"\"\"Count the functions in the active binary view. + \"\"\"Count the functions in the selected binary view. :param min_size: Only count functions with at least this many bytes. \"\"\" @@ -63,7 +63,9 @@ def myplugin_function_count(call: mcp.ToolCall, min_size: int = 0) -> dict: "Tool", "ToolCall", "ToolError", + "ToolGroup", "ToolResult", + "register_group", "register_tool", "tool", ] @@ -239,7 +241,7 @@ def __del__(self): @property def binary_view(self) -> Optional["binaryview.BinaryView"]: - """The binary view the MCP session targets. Never ``None`` while a BinaryView-scoped tool runs.""" + """The binary view for this tool call. Never ``None`` while a BinaryView-scoped tool runs.""" handle = core.BNGetMcpToolCallBinaryView(self.handle) if not handle: return None @@ -276,6 +278,65 @@ def _parse(self, parse, value: Any, here: int) -> int: return result.value +class _ToolGroupMetaclass(type): + def __iter__(cls): + binaryninja._init_plugins() + count = ctypes.c_ulonglong() + groups = core.BNGetMcpToolGroupList(count) + try: + for i in range(count.value): + yield ToolGroup(core.BNNewMcpToolGroupReference(groups[i])) + finally: + core.BNFreeMcpToolGroupList(groups, count.value) + + +class ToolGroup(metaclass=_ToolGroupMetaclass): + def __init__(self, handle): + self.handle = handle + + def __del__(self): + if core is not None: + core.BNFreeMcpToolGroup(self.handle) + + def __eq__(self, other): + if not isinstance(other, ToolGroup): + return NotImplemented + return ctypes.addressof(self.handle.contents) == ctypes.addressof(other.handle.contents) + + def __hash__(self): + return hash(ctypes.addressof(self.handle.contents)) + + @staticmethod + def list() -> List["ToolGroup"]: + return list(ToolGroup) + + @staticmethod + def by_name(name: str) -> Optional["ToolGroup"]: + binaryninja._init_plugins() + handle = core.BNGetMcpToolGroupByName(name) + return ToolGroup(handle) if handle else None + + @property + def name(self) -> str: + return core.BNGetMcpToolGroupName(self.handle) + + @property + def title(self) -> str: + return core.BNGetMcpToolGroupTitle(self.handle) + + @property + def description(self) -> str: + return core.BNGetMcpToolGroupDescription(self.handle) + + @property + def enabled_by_default(self) -> bool: + return core.BNIsMcpToolGroupEnabledByDefault(self.handle) + + @property + def enabled(self) -> bool: + return core.BNIsMcpToolGroupEnabled(self.handle) + + class _ToolMetaclass(type): def __iter__(cls): binaryninja._init_plugins() @@ -314,6 +375,10 @@ def by_name(name: str) -> Optional["Tool"]: def name(self) -> str: return core.BNGetMcpToolName(self.handle) + @property + def group(self) -> ToolGroup: + return ToolGroup(core.BNGetMcpToolGroup(self.handle)) + @property def title(self) -> str: return core.BNGetMcpToolTitle(self.handle) @@ -409,6 +474,19 @@ def _invoke(self, ctxt, call_handle, arguments, result_handle): log_error_for_exception("Unhandled Python exception applying an MCP tool result") +def register_group(name: str, title: str, description: str, *, enabled_by_default: bool = True) -> ToolGroup: + """Register an MCP tool group and its call permissions.""" + definition = core.BNMcpToolGroupDefinition() + definition.name = name + definition.title = title + definition.description = description + definition.enabledByDefault = enabled_by_default + handle = core.BNRegisterMcpToolGroup(definition) + if not handle: + raise ValueError(f"Binary Ninja rejected the MCP group '{name}'") + return ToolGroup(handle) + + def register_tool( name: str, description: str, @@ -419,6 +497,7 @@ def register_tool( scope: McpToolScope = McpToolScope.BinaryViewScope, annotations: McpToolAnnotation = McpToolAnnotation(0), output_schema: Optional[dict] = None, + group: Optional[ToolGroup] = None, ) -> Tool: """ Registers a tool for the life of the process. @@ -427,6 +506,8 @@ def register_tool( ``str``, ``dict``, :py:class:`ToolResult` or ``None``, or raises :py:class:`ToolError`. :raises ValueError: The definition is invalid or its name is already registered. """ + if group is not None and not isinstance(group, ToolGroup): + raise TypeError("group must be a ToolGroup") registered = _RegisteredTool(handler) definition = core.BNMcpToolDefinition() definition.name = name @@ -436,6 +517,7 @@ def register_tool( definition.outputSchema = json.dumps(output_schema) if output_schema is not None else None definition.scope = scope definition.annotations = annotations + definition.group = group.handle if group is not None else None handle = core.BNRegisterMcpTool(definition, registered.callbacks) if not handle: raise ValueError(f"Binary Ninja rejected the MCP tool '{name}'; see the log for why") @@ -713,6 +795,7 @@ def tool( idempotent: bool = False, open_world: bool = False, output_schema: Optional[dict] = None, + group: Optional[ToolGroup] = None, ): """ Registers the decorated function as a tool. The function's first parameter receives the @@ -792,6 +875,7 @@ def handler(call: ToolCall, arguments: Any) -> Any: scope=scope, annotations=annotations, output_schema=output_schema, + group=group, ) return function diff --git a/rust/src/mcp/server.rs b/rust/src/mcp/server.rs index 5bdad48bc9..04b2b57adc 100644 --- a/rust/src/mcp/server.rs +++ b/rust/src/mcp/server.rs @@ -27,7 +27,7 @@ use crate::rc::*; use crate::string::{BnString, IntoCStr}; /// Creates a tool without registering it. This can be used to expose a tool that is specific to one -/// MCP server, such as one that manages that server's active binary view. Otherwise the same as +/// MCP server, such as one that opens and lists binaries. Otherwise the same as /// [`register_mcp_tool`](super::tool::register_mcp_tool). pub fn create_mcp_tool(tool: T) -> Option> { create_with(tool, BNCreateMcpTool) diff --git a/rust/src/mcp/tool.rs b/rust/src/mcp/tool.rs index 072549f8ca..de3fef9869 100644 --- a/rust/src/mcp/tool.rs +++ b/rust/src/mcp/tool.rs @@ -43,6 +43,136 @@ bitflags::bitflags! { } } +#[derive(Clone, Debug)] +pub struct McpToolGroupDefinition { + pub name: String, + pub title: String, + pub description: String, + pub enabled_by_default: bool, +} + +impl McpToolGroupDefinition { + pub fn new( + name: impl Into, + title: impl Into, + description: impl Into, + ) -> Self { + Self { + name: name.into(), + title: title.into(), + description: description.into(), + enabled_by_default: true, + } + } +} + +#[derive(PartialEq, Eq, Hash)] +pub struct McpToolGroup { + handle: *mut BNMcpToolGroup, +} + +impl McpToolGroup { + unsafe fn from_raw(handle: *mut BNMcpToolGroup) -> Self { + debug_assert!(!handle.is_null()); + Self { handle } + } + + unsafe fn ref_from_raw(handle: *mut BNMcpToolGroup) -> Ref { + Ref::new(Self::from_raw(handle)) + } + + pub fn register(definition: &McpToolGroupDefinition) -> Option> { + let name = CString::new(definition.name.as_str()).ok()?; + let title = CString::new(definition.title.as_str()).ok()?; + let description = CString::new(definition.description.as_str()).ok()?; + let definition = BNMcpToolGroupDefinition { + name: name.as_ptr(), + title: title.as_ptr(), + description: description.as_ptr(), + enabledByDefault: definition.enabled_by_default, + }; + let handle = unsafe { BNRegisterMcpToolGroup(&definition) }; + (!handle.is_null()).then(|| unsafe { Self::ref_from_raw(handle) }) + } + + pub fn list() -> Array { + let mut count = 0; + let groups = unsafe { BNGetMcpToolGroupList(&mut count) }; + unsafe { Array::new(groups, count, ()) } + } + + pub fn from_name(name: &str) -> Option> { + let name = name.to_cstr(); + let handle = unsafe { BNGetMcpToolGroupByName(name.as_ptr()) }; + (!handle.is_null()).then(|| unsafe { Self::ref_from_raw(handle) }) + } + + pub fn name(&self) -> String { + unsafe { BnString::into_string(BNGetMcpToolGroupName(self.handle)) } + } + + pub fn title(&self) -> String { + unsafe { BnString::into_string(BNGetMcpToolGroupTitle(self.handle)) } + } + + pub fn description(&self) -> String { + unsafe { BnString::into_string(BNGetMcpToolGroupDescription(self.handle)) } + } + + pub fn enabled_by_default(&self) -> bool { + unsafe { BNIsMcpToolGroupEnabledByDefault(self.handle) } + } + + pub fn enabled(&self) -> bool { + unsafe { BNIsMcpToolGroupEnabled(self.handle) } + } +} + +impl fmt::Debug for McpToolGroup { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("McpToolGroup") + .field("name", &self.name()) + .finish() + } +} + +unsafe impl Send for McpToolGroup {} +unsafe impl Sync for McpToolGroup {} + +impl ToOwned for McpToolGroup { + type Owned = Ref; + + fn to_owned(&self) -> Self::Owned { + unsafe { RefCountable::inc_ref(self) } + } +} + +unsafe impl RefCountable for McpToolGroup { + unsafe fn inc_ref(handle: &Self) -> Ref { + Self::ref_from_raw(BNNewMcpToolGroupReference(handle.handle)) + } + + unsafe fn dec_ref(handle: &Self) { + BNFreeMcpToolGroup(handle.handle); + } +} + +impl CoreArrayProvider for McpToolGroup { + type Raw = *mut BNMcpToolGroup; + type Context = (); + type Wrapped<'a> = Guard<'a, McpToolGroup>; +} + +unsafe impl CoreArrayProviderInner for McpToolGroup { + unsafe fn free(raw: *mut Self::Raw, count: usize, _context: &Self::Context) { + BNFreeMcpToolGroupList(raw, count); + } + + unsafe fn wrap_raw<'a>(raw: &'a Self::Raw, context: &'a Self::Context) -> Self::Wrapped<'a> { + Guard::new(McpToolGroup::from_raw(*raw), context) + } +} + /// Everything about a tool except its input schema. #[derive(Clone, Debug)] pub struct McpToolInfo { @@ -52,6 +182,7 @@ pub struct McpToolInfo { pub scope: McpToolScope, pub annotations: McpToolAnnotations, pub output_schema: Option, + pub group: Option>, } impl McpToolInfo { @@ -64,6 +195,7 @@ impl McpToolInfo { scope: McpToolScope::BinaryViewScope, annotations: McpToolAnnotations::empty(), output_schema: None, + group: None, } } @@ -82,6 +214,11 @@ impl McpToolInfo { self } + pub fn with_group(mut self, group: Ref) -> Self { + self.group = Some(group); + self + } + pub fn with_output_schema(mut self, schema: Value) -> Self { self.output_schema = Some(schema); self @@ -252,19 +389,16 @@ impl McpToolCall { Ref::new(Self { handle }) } - /// The binary view the MCP session targets. Never `None` while a BinaryView-scoped tool runs. + /// The binary view for this tool call. Never `None` while a BinaryView-scoped tool runs. pub fn binary_view(&self) -> Option> { let view = unsafe { BNGetMcpToolCallBinaryView(self.handle) }; (!view.is_null()).then(|| unsafe { BinaryView::ref_from_raw(view) }) } - /// The binary view, or the `no_active_binary_view` error to return with `?` when there is none. + /// The binary view, or the `binary_not_found` error to return with `?` when there is none. pub fn require_binary_view(&self) -> Result, McpToolError> { self.binary_view().ok_or_else(|| { - McpToolError::new( - "no_active_binary_view", - "No active Binary Ninja binary view is selected", - ) + McpToolError::new("binary_not_found", "The call has no binary to run against") }) } @@ -413,6 +547,11 @@ pub(super) fn create_with( .map_or(ptr::null(), |schema| schema.as_ptr()), scope: definition.info.scope, annotations: definition.info.annotations.bits(), + group: definition + .info + .group + .as_ref() + .map_or(ptr::null_mut(), |group| group.handle), }; extern "C" fn cb_free_object(ctxt: *mut c_void) { @@ -469,6 +608,10 @@ impl CoreMcpTool { unsafe { BnString::into_string(BNGetMcpToolName(self.handle)) } } + pub fn group(&self) -> Ref { + unsafe { McpToolGroup::ref_from_raw(BNGetMcpToolGroup(self.handle)) } + } + pub fn title(&self) -> String { unsafe { BnString::into_string(BNGetMcpToolTitle(self.handle)) } } diff --git a/rust/tests/mcp.rs b/rust/tests/mcp.rs index 72549ef66b..42b564cdce 100644 --- a/rust/tests/mcp.rs +++ b/rust/tests/mcp.rs @@ -4,8 +4,8 @@ use binaryninja::headless::Session; use binaryninja::mcp::server::{create_mcp_tool, invoke_mcp_tool, McpToolCallHost}; use binaryninja::mcp::tool::{ empty_input_schema, register_mcp_tool, CoreMcpTool, McpIntegerExpression, McpTool, - McpToolAnnotations, McpToolCall, McpToolDefinition, McpToolError, McpToolInfo, McpToolResult, - McpToolScope, + McpToolAnnotations, McpToolCall, McpToolDefinition, McpToolError, McpToolGroup, + McpToolGroupDefinition, McpToolInfo, McpToolResult, McpToolScope, }; use binaryninja::rc::Ref; use serde_json::{json, Value}; @@ -155,6 +155,67 @@ fn error_code(result: &Value) -> &str { .unwrap_or_default() } +#[test] +fn registers_groups_and_retains_tool_membership() { + let _session = Session::new().expect("Failed to initialize session"); + let definition = + McpToolGroupDefinition::new("rust_mcp_group", "Rust Group", "Test grouped tools."); + let group = McpToolGroup::register(&definition).expect("group registration failed"); + assert_eq!(group.name(), "rust_mcp_group"); + assert_eq!(group.title(), "Rust Group"); + assert_eq!(group.description(), "Test grouped tools."); + assert!(group.enabled_by_default()); + assert!(group.enabled()); + assert_eq!(McpToolGroup::register(&definition), Some(group.clone())); + assert_eq!( + McpToolGroup::from_name("rust_mcp_group"), + Some(group.clone()) + ); + assert!(McpToolGroup::list() + .iter() + .any(|item| item.name() == group.name())); + + struct GroupedTool(Ref); + + impl McpTool for GroupedTool { + fn definition(&self) -> McpToolDefinition { + McpToolInfo::new("rust_mcp_grouped_tool", "Return a value.") + .with_scope(McpToolScope::GlobalScope) + .with_group(self.0.clone()) + .with_input_schema(empty_input_schema()) + } + + fn invoke( + &self, + _call: &McpToolCall, + _arguments: Value, + ) -> Result { + Ok(McpToolResult::structured(json!({"value": 17}))) + } + } + + let tool = register_mcp_tool(GroupedTool(group.clone())).expect("tool registration failed"); + drop(group); + assert_eq!(tool.group().name(), "rust_mcp_group"); + assert_eq!( + invoke(&tool, &json!({}), None)["structuredContent"]["value"], + 17 + ); + let settings = binaryninja::settings::Settings::global(); + settings.set_bool("mcp.groups.rust_mcp_group", false); + assert!(!tool.group().enabled()); + assert_eq!( + error_code(&invoke(&tool, &json!({}), None)), + "group_disabled" + ); + settings.set_bool("mcp.groups.rust_mcp_group", true); + assert!(tool.group().enabled()); + assert_eq!( + invoke(&tool, &json!({}), None)["structuredContent"]["value"], + 17 + ); +} + #[test] fn registers_lists_and_describes_tools() { let _session = Session::new().expect("Failed to initialize session"); @@ -272,7 +333,7 @@ fn binary_view_scope_requires_a_view() { assert_eq!( error_code(&invoke(&tool, &json!({}), None)), - "no_active_binary_view" + "binary_not_found" ); let view = raw_view();