Skip to content

feat: Add Geometry Node Module#92

Open
duqich wants to merge 6 commits into
ahujasid:mainfrom
duqich:main
Open

feat: Add Geometry Node Module#92
duqich wants to merge 6 commits into
ahujasid:mainfrom
duqich:main

Conversation

@duqich

@duqich duqich commented Mar 31, 2025

Copy link
Copy Markdown

User description

Hello,

Over the past few weeks, I’ve been very excited about this project but also concerned that 3D models generated by LLMs through code are not easily controllable or adjustable.

To address this, I’m adding a module that enables procedural 3D model creation using Blender’s Geometry Nodes. With this module, models can expose adjustable parameters, making it much easier for users to fine-tune and accelerate the creation of their own 3D asset libraries.

After some basic tests described in the README, I believe it’s now ready for submission. (By the way, I used Cursor + Claude to test my examples.)


PR Type

Enhancement, Documentation


Description

  • Introduced a Geometry Node Module for procedural 3D modeling.

  • Added support for creating and managing geometry node networks.

  • Enhanced Blender MCP server with geometry node-related commands.

  • Updated documentation with examples for the new Geometry Node Module.


Changes walkthrough 📝

Relevant files
Enhancement
addon.py
Introduced Geometry Node Module and UI integration.           

addon.py

  • Added data classes for geometry node definitions and networks.
  • Implemented methods for creating and managing geometry node networks.
  • Integrated geometry nodes into the Blender MCP UI and server.
  • Added support for enabling/disabling geometry nodes in the UI.
  • +1276/-247
    server.py
    Added geometry node commands to MCP server.                           

    src/blender_mcp/server.py

  • Added commands for geometry node operations.
  • Integrated geometry node status checks into the server.
  • Implemented tools for querying and creating geometry node networks.
  • +234/-3 
    Documentation
    README.md
    Updated documentation for Geometry Node Module.                   

    README.md

  • Documented the new Geometry Node Module.
  • Added example commands for procedural modeling.
  • Updated feature list to include geometry nodes.
  • +12/-0   

    Need help?
  • Type /help how to ... in the comments thread for any questions about Qodo Merge usage.
  • Check out the documentation for more information.
  • Summary by CodeRabbit

    Release Notes

    • New Features
      • Geometry nodes support: create, manage, and query node networks with status tracking.
      • Viewport screenshot and direct Blender code execution capabilities.
      • Expanded asset library integrations including Polyhaven, Sketchfab, Hyper3D/Rodin, and Hunyuan3D for asset discovery, preview, download, and generation.
      • Enhanced connection stability with extended communication timeouts.
      • Usage analytics and telemetry instrumentation.

    ✏️ Tip: You can customize this high-level summary in your review settings.

    @coderabbitai

    coderabbitai Bot commented Mar 31, 2025

    Copy link
    Copy Markdown
    📝 Walkthrough

    Walkthrough

    This PR extends the Blender MCP server with geometry nodes support, adds integrations for multiple asset libraries (Polyhaven, Sketchfab, Hyper3D, Hunyuan3D), introduces viewport screenshot and code execution tools, and implements telemetry instrumentation across the platform.

    Changes

    Cohort / File(s) Summary
    Server API and Tool Definitions
    src/blender_mcp/server.py
    Added 18+ new public tools for geometry nodes (status query, node info retrieval, node network creation), asset library integrations (Polyhaven categories/search/download, Sketchfab model search/preview/download, Hyper3D/Hunyuan3D generation/polling/import), viewport screenshot capture, Blender code execution, and enhanced PolyHaven/Sketchfab status reporting. Extended asset creation strategy with dedicated geometry nodes workflow. Integrated telemetry instrumentation via decorators and startup/shutdown hooks. Extended BlenderConnection with 180s socket timeouts and geometry nodes status fetching. Added DEFAULT_HOST and DEFAULT_PORT configuration constants.
    Addon Implementation and Data Models
    addon.py
    Introduced 6 new data classes (NodeDefinition, NodeLink, GeometryNodeNetwork, SocketInfo, PropertyInfo, NodeInfo) for geometry node representation. Implemented geometry nodes feature including complete_geometry_node(), get_geometry_nodes_status(), and 15+ internal helper methods for node creation, linking, property assignment, and validation across Blender 3.x/4.x versions. Added UI preference blendermcp_use_geometry_nodes for geometry nodes integration toggle. Integrated geometry nodes commands into command dispatcher and conditionally wired feature handlers.

    Estimated code review effort

    🎯 4 (Complex) | ⏱️ ~45 minutes

    Poem

    🐰 Hopping through nodes with glee,
    New features bloom on every tree,
    From SketchFab models to Hyper3D dreams,
    This update flows in powerful streams,
    Geometry nodes dance, assets sing,
    What magic shall CodeRabbit bring!

    🚥 Pre-merge checks | ✅ 3
    ✅ Passed checks (3 passed)
    Check name Status Explanation
    Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
    Title check ✅ Passed The title 'feat: Add Geometry Node Module' accurately describes the primary change—the introduction of geometry node functionality with new data structures, commands, and UI integration across the codebase.
    Docstring Coverage ✅ Passed Docstring coverage is 89.41% which is sufficient. The required threshold is 80.00%.

    ✏️ Tip: You can configure your own custom pre-merge checks in the settings.

    ✨ Finishing touches
    • 📝 Generate docstrings

    Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

    ❤️ Share

    Comment @coderabbitai help to get the list of available commands and usage tips.

    @qodo-code-review

    Copy link
    Copy Markdown

    PR Reviewer Guide 🔍

    Here are some key observations to aid the review process:

    ⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
    🧪 No relevant tests
    🔒 No security concerns identified
    ⚡ Recommended focus areas for review

    Error Handling

    The get_node_info function has nested try-except blocks that could lead to confusing error messages. The inner try block handles parameter validation errors but the outer try catches all exceptions, potentially masking specific errors.

    try:
        blender = get_blender_connection()
    
        # Check if geometry nodes feature is enabled
        if not _geometry_nodes_enabled:
            return "Geometry nodes feature is disabled. Please enable this feature in the Blender MCP panel."
    
        # Ensure parameter types are correct
        try:
            # Process include_details parameter, ensure it's a boolean
            if isinstance(include_details, str):
                include_details_lower = include_details.lower()
                include_details = include_details_lower == 'true' or include_details_lower == 'yes' or include_details_lower == '1'
    
            # Process node_type_name parameter, support comma-separated string
            if node_type_name is not None:
                # If it's a string, check if it's a comma-separated list
                if isinstance(node_type_name, str):
                    # Check if it's a multi-node name with commas
                    if ',' in node_type_name:
                        # Split by comma and remove whitespace
                        node_type_name = [name.strip() for name in node_type_name.split(',') if name.strip()]
    
                # Make sure parameter type is correct
                if not isinstance(node_type_name, (str, list)):
                    return f"Error: node_type_name parameter must be a string or list of strings, received: {type(node_type_name)}"
    
                logger.info(f"Getting node info: format={output_format}, details={include_details}, nodes={node_type_name}")
    
            # Ensure output_format is text or json
            if output_format not in ('text', 'json'):
                return f"Error: Unsupported output format: {output_format}, please use 'text' or 'json'"
    
            result = blender.send_command("get_node_info", {
                "output_format": output_format,
                "include_details": include_details,
                "node_type_name": node_type_name
            })
    
            if isinstance(result, dict) and "error" in result:
                return f"Error: {result['error']}"
    
            # Validate returned data
            if result is None or len(str(result).strip()) == 0:
                return "Error: Node information from Blender is empty"
    
            return result
        except ValueError as e:
            return f"Parameter error: {str(e)}"
    except Exception as e:
        logger.error(f"Error getting node info: {str(e)}")
        return f"Error getting node info: {str(e)}"
    Parameter Validation

    The complete_geometry_node function lacks input validation for the nodes, links, and input_sockets parameters. Missing validation could lead to unexpected behavior when invalid data is passed.

    def complete_geometry_node(
        ctx: Context,
        object_name: str,
        nodes: list,
        links: list,
        input_sockets: list = None
    ) -> str:
        """
        Create or complete a geometry node network for procedural modeling and geometry generation. (No need to set materials)
    
        [IMPORTANT] Geometry node creation workflow:
        1. Must first use get_node_info() to understand node types and properties
        2. When building nodes and links definitions, note:
           - No need to manually create "Group Input" and "Group Output" nodes
           - Use "input" and "output" as special identifiers to reference these automatically created nodes
           - Example: {"from_node": "input", "from_socket": "Geometry", "to_node": 0, "to_socket": "Mesh"}
        3. No need to create objects in advance with create_object(), this function will automatically create them
        4. When nodes have the same socket names, use indices instead of names
    
        Parameters:
        - object_name: Name of the object to apply geometry nodes to (will be created automatically)
        - nodes: List of node definitions, each containing type, location, label, etc.
          Format: [{"type": "NodeType", "location": [x, y], "label": "Label", "inputs": {...}, "properties": {...}}]
        - links: List of node connections
          Format: [{"from_node": source_node, "from_socket": source_socket, "to_node": target_node, "to_socket": target_socket}]
        - input_sockets: Optional node group input interface definitions
          Format: [{"name": "SocketName", "type": "SocketType", "value": default_value}]
          - Socket types like: NodeSocketFloat, NodeSocketVector, etc.
          - value is optional, different socket types support different value types:
            * NodeSocketFloat: float number
            * NodeSocketInt: integer
            * NodeSocketVector: [x, y, z] list
            * NodeSocketColor: [r, g, b, a] list
    
        Returns:
        Creation result and status information
    
        Usage example:
        ```python
        # Use geometry nodes to create points distributed on a sphere surface with exposed radius and point density parameters
    
        # Define needed nodes
        nodes = [
            # Create UV sphere
            {"type": "GeometryNodeMeshUVSphere", "location": [-200, 0], "inputs": {"Segments": 32, "Rings": 16}},
            # Distribute points on sphere surface
            {"type": "GeometryNodeDistributePointsOnFaces", "location": [0, 0], "properties": {"distribute_method": "POISSON"}}
        ]
    
        # Define connections between nodes
        links = [
            # Connect input parameters to nodes
            {"from_node": "input", "from_socket": "Radius", "to_node": 0, "to_socket": "Radius"},
            {"from_node": "input", "from_socket": "Density", "to_node": 1, "to_socket": "Density Max"},
    
            # Connect between nodes
            {"from_node": 0, "from_socket": "Mesh", "to_node": 1, "to_socket": "Mesh"},
    
            # Connect to output
            {"from_node": 1, "from_socket": "Points", "to_node": "output", "to_socket": "Geometry"}
        ]
    
        # Define input interfaces with default values
        input_sockets = [
            {"name": "Geometry", "type": "NodeSocketGeometry"},
            {"name": "Radius", "type": "NodeSocketFloat", "value": 2.0},
            {"name": "Density", "type": "NodeSocketFloat", "value": 50.0}
        ]
    
        # Create geometry nodes
        result = complete_geometry_node(
            object_name="SpherePoints", 
            nodes=nodes, 
            links=links,
            input_sockets=input_sockets,
        )
        ```
    
        returns:
        Detailed data containing operation status and created node network information
        """
        try:
            blender = get_blender_connection()
    
            # Check if geometry nodes feature is enabled
            if not _geometry_nodes_enabled:
                return "Geometry nodes feature is disabled. Please enable this feature in the Blender MCP panel."
    
            result = blender.send_command("complete_geometry_node", {
                "object_name": object_name,
                "nodes": nodes,
                "links": links,
                "input_sockets": input_sockets,
            })
    
            return json.dumps(result, indent=2)
        except Exception as e:
            logger.error(f"Error completing geometry node network: {str(e)}")
            return f"Error completing geometry node network: {str(e)}"
    Documentation Clarity

    The docstring examples in complete_geometry_node function show a different parameter (Segments/Rings) than what's described in the parameter documentation. This inconsistency could confuse users.

    ```python
    # Use geometry nodes to create points distributed on a sphere surface with exposed radius and point density parameters
    
    # Define needed nodes
    nodes = [
        # Create UV sphere
        {"type": "GeometryNodeMeshUVSphere", "location": [-200, 0], "inputs": {"Segments": 32, "Rings": 16}},
        # Distribute points on sphere surface
        {"type": "GeometryNodeDistributePointsOnFaces", "location": [0, 0], "properties": {"distribute_method": "POISSON"}}
    ]
    
    # Define connections between nodes
    links = [
        # Connect input parameters to nodes
        {"from_node": "input", "from_socket": "Radius", "to_node": 0, "to_socket": "Radius"},
        {"from_node": "input", "from_socket": "Density", "to_node": 1, "to_socket": "Density Max"},
    
        # Connect between nodes
        {"from_node": 0, "from_socket": "Mesh", "to_node": 1, "to_socket": "Mesh"},
    
        # Connect to output
        {"from_node": 1, "from_socket": "Points", "to_node": "output", "to_socket": "Geometry"}
    ]
    
    # Define input interfaces with default values
    input_sockets = [
        {"name": "Geometry", "type": "NodeSocketGeometry"},
        {"name": "Radius", "type": "NodeSocketFloat", "value": 2.0},
        {"name": "Density", "type": "NodeSocketFloat", "value": 50.0}
    ]
    
    # Create geometry nodes
    result = complete_geometry_node(
        object_name="SpherePoints", 
        nodes=nodes, 
        links=links,
        input_sockets=input_sockets,
    )

    returns:
    Detailed data containing operation status and created node network information
    """

    
    </details>
    
    </td></tr>
    </table>
    

    @qodo-code-review

    qodo-code-review Bot commented Mar 31, 2025

    Copy link
    Copy Markdown

    PR Code Suggestions ✨

    Explore these optional code suggestions:

    CategorySuggestion                                                                                                                                    Impact
    Possible issue
    Missing object creation method
    Suggestion Impact:The commit reworked `complete_geometry_node()` to ensure an object is created when missing via a new `_get_or_create_object()` helper, which calls `_create_geometry_nodes_object()`. The `_create_geometry_nodes_object()` method is present in the updated code (and was adjusted in the patch), resolving the previously referenced-but-missing helper issue (without inlining the exact code from the suggestion).

    code diff:

         # region GeometryNodeCreator
         def complete_geometry_node(self, object_name, nodes, links, input_sockets=None):
    -        """Complete geometry node network creation
    -
    -        Args:
    -            object_name: Object name
    -            nodes: List of node definitions
    -            links: List of node connections
    -            input_sockets: Node group input interface definitions [{"name": "Name", "type": "Type", "value": "DefaultValue"}]
    -
    -        Returns:
    -            dict: Dictionary containing operation status and related information
    -        """
    +        """Complete geometry node network creation"""
             try:
    -            obj = bpy.data.objects.get(object_name)
    -            if not obj:
    -                result = self._create_geometry_nodes_object(object_name)
    -                if "error" in result:
    -                    return result
    -                obj = bpy.data.objects.get(object_name)
    -
    -            # Find geometry nodes modifier
    -            geometry_modifier = None
    -            for modifier in obj.modifiers:
    -                if modifier.type == 'NODES':
    -                    geometry_modifier = modifier
    -                    break
    -
    -            if geometry_modifier and geometry_modifier.node_group:
    -                old_node_group_name = geometry_modifier.node_group.name
    -                geometry_modifier.node_group = None
    -
    -                # Try to delete the old node group
    -                old_node_group = bpy.data.node_groups.get(old_node_group_name)
    -                if old_node_group:
    -                    bpy.data.node_groups.remove(old_node_group)
    -
    -            # If there's no geometry nodes modifier, create one
    -            if not geometry_modifier:
    -                geometry_modifier = obj.modifiers.new(name="GeometryNodes", type='NODES')
    -
    -            # Create a new node group
    -            node_group = bpy.data.node_groups.new(name=f"{object_name}_geometry", type='GeometryNodeTree')
    -            if IS_BLENDER_4:
    -                node_group.is_modifier = True
    -
    -            # Set the node group for the modifier
    -            geometry_modifier.node_group = node_group
    -
    -            has_input_node = False
    -            has_output_node = False
    -            for node_data in nodes:
    -                node_type = node_data.get("type", "")
    -                if node_type in ['NodeGroupInput', 'GroupInput']:
    -                    has_input_node = True
    -                elif node_type in ['NodeGroupOutput', 'GroupOutput']:
    -                    has_output_node = True
    -
    -            self._setup_node_group_interface(node_group, input_sockets)
    -
    -            # First create all nodes
    -            created_nodes = []
    -
    -            input_node = None
    -            output_node = None
    -
    -            if not has_input_node:
    -                input_node = node_group.nodes.new('NodeGroupInput')
    -                input_node.location = (-300, 0)
    -
    -            if not has_output_node:
    -                output_node = node_group.nodes.new('NodeGroupOutput')
    -                output_node.location = (500, 0)
    -
    -            for node_data in nodes:
    -                try:
    -                    node_type = node_data["type"]
    -
    -                    node = node_group.nodes.new(node_type)
    -
    -                    if "location" in node_data:
    -                        node.location = node_data["location"]
    -
    -                    if "label" in node_data:
    -                        node.label = node_data["label"]
    -
    -                    if "properties" in node_data:
    -                        for prop_name, prop_value in node_data["properties"].items():
    -                            if hasattr(node, prop_name):
    -                                try:
    -                                    setattr(node, prop_name, prop_value)
    -                                except Exception as prop_error:
    -                                    print(f"Error setting property {prop_name}: {prop_error}")
    -
    -                    if "inputs" in node_data:
    -                        for input_name, input_value in node_data["inputs"].items():
    -                            for input in node.inputs:
    -                                if input.name == input_name and hasattr(input, 'default_value'):
    -                                    try:
    -                                        if hasattr(input.default_value, '__len__'):
    -                                            for i, val in enumerate(input_value):
    -                                                if i < len(input.default_value):
    -                                                    input.default_value[i] = val
    -                                        else:
    -                                            input.default_value = input_value
    -                                    except Exception as input_error:
    -                                        print(f"Error setting input value: {input_error}")
    -
    -                    node_info = {
    -                        "name": node.name,
    -                        "type": node.type,
    -                        "location": [node.location.x, node.location.y]
    -                    }
    -
    -                    created_nodes.append(node_info)
    -                except Exception as node_error:
    -                    return {"error": f"Error creating node {node_data['type']}: {str(node_error)}"}
    -
    -            # Create links
    -            created_links = []
    -
    -            for link_data in links:
    -                try:
    -                    # Find nodes by index or name
    -                    from_node_id = link_data["from_node"]
    -                    to_node_id = link_data["to_node"]
    -
    -                    from_node = None
    -                    to_node = None
    -
    -                    # Special handling for "input" and "output" identifiers
    -                    if from_node_id == "input":
    -                        from_node = input_node
    -                    elif from_node_id == "output":
    -                        from_node = output_node
    -                    elif isinstance(from_node_id, int):
    -                        # Assume integer index points to created node
    -                        if 0 <= from_node_id < len(created_nodes):
    -                            from_node = node_group.nodes.get(created_nodes[from_node_id]["name"])
    -                    else:
    -                        # Find by name
    -                        from_node = node_group.nodes.get(from_node_id)
    -
    -                    if to_node_id == "input":
    -                        to_node = input_node
    -                    elif to_node_id == "output":
    -                        to_node = output_node
    -                    elif isinstance(to_node_id, int):
    -                        # Assume integer index points to created node
    -                        if 0 <= to_node_id < len(created_nodes):
    -                            to_node = node_group.nodes.get(created_nodes[to_node_id]["name"])
    -                    else:
    -                        # Find by name
    -                        to_node = node_group.nodes.get(to_node_id)
    -
    -                    if not from_node:
    -                        return {"error": f"Could not find source node: {from_node_id}"}
    -
    -                    if not to_node:
    -                        return {"error": f"Could not find target node: {to_node_id}"}
    -
    -                    # Find socket
    -                    from_socket_id = link_data["from_socket"]
    -                    to_socket_id = link_data["to_socket"]
    -
    -                    from_socket = None
    -                    to_socket = None
    -
    -                    # Find socket by index or name
    -                    if isinstance(from_socket_id, int):
    -                        if 0 <= from_socket_id < len(from_node.outputs):
    -                            from_socket = from_node.outputs[from_socket_id]
    -                    else:
    -                        # Search by name
    -                        for socket in from_node.outputs:
    -                            if socket.name == from_socket_id:
    -                                from_socket = socket
    -                                break
    -
    -                    if isinstance(to_socket_id, int):
    -                        if 0 <= to_socket_id < len(to_node.inputs):
    -                            to_socket = to_node.inputs[to_socket_id]
    -                    else:
    -                        # Search by name
    -                        for socket in to_node.inputs:
    -                            if socket.name == to_socket_id:
    -                                to_socket = socket
    -                                break
    -
    -                    if not from_socket:
    -                        return {"error": f"Could not find source socket: {from_socket_id}"}
    -
    -                    if not to_socket:
    -                        return {"error": f"Could not find target socket: {to_socket_id}"}
    -
    -                    # Create connection
    -                    link = node_group.links.new(from_socket, to_socket)
    -
    -                    # Record created link
    -                    link_info = {
    -                        "from_node": from_node.name,
    -                        "from_socket": from_socket.name,
    -                        "to_node": to_node.name,
    -                        "to_socket": to_socket.name
    -                    }
    -
    -                    created_links.append(link_info)
    -                except Exception as link_error:
    -                    return {"error": f"Error creating link: {str(link_error)}"}
    -
    -            # If input sockets are provided and contain values, set modifier parameters
    +            obj = self._get_or_create_object(object_name)
    +            if isinstance(obj, dict) and "error" in obj:
    +                return obj
    +                
    +            geometry_modifier = self._setup_geometry_modifier(obj, object_name)
    +            node_group = geometry_modifier.node_group
    +            
    +            input_node, output_node = self._create_default_nodes(node_group, nodes)
    +            created_nodes = self._create_nodes(node_group, nodes)
    +            created_links = self._create_links(node_group, links, created_nodes, input_node, output_node)
    +            
                 if input_sockets:
    -                try:
    -                    # Find the NodeGroupInput node
    -                    group_input_node = None
    -                    for node in node_group.nodes:
    -                        if node.type == 'GROUP_INPUT':
    -                            group_input_node = node
    -                            break
    -
    -                    # Create one if not found
    -                    if not group_input_node:
    -                        group_input_node = node_group.nodes.new('NodeGroupInput')
    -
    -                    # Get socket information directly from the NodeGroupInput node
    -                    socket_dict = {}
    -                    for i, output in enumerate(group_input_node.outputs):
    -                        socket_dict[output.name] = i
    -
    -                    # Output debug information
    -                    print(f"Sockets from NodeGroupInput: {socket_dict}")
    -
    -                    # Set modifier parameter values
    -                    for socket in input_sockets:
    -                        if "value" in socket and socket.get("type") != "NodeSocketGeometry":
    -                            socket_name = socket.get("name")
    -
    -                            if socket_name in socket_dict:
    -                                socket_index = socket_dict[socket_name]
    -                                socket_key = f"Socket_{socket_index}"
    -
    -                                try:
    -                                    if socket_key in geometry_modifier:
    -                                        geometry_modifier[socket_key] = socket["value"]
    -                                        print(
    -                                            f"Successfully set parameter {socket_name} ({socket_key}) = {socket['value']}")
    -                                    else:
    -                                        print(f"Warning: {socket_key} does not exist in modifier")
    -                                except Exception as e:
    -                                    print(f"Error when setting modifier parameter {socket_key}: {str(e)}")
    -                            else:
    -                                print(f"Warning: Could not find socket named '{socket_name}' in NodeGroupInput")
    -
    -                    # Output all available Socket keys for debugging
    -                    available_sockets = [key for key in geometry_modifier.keys() if key.startswith("Socket_")]
    -                    if available_sockets:
    -                        print(f"Available Socket keys in modifier: {available_sockets}")
    -
    -                except Exception as e:
    -                    print(f"Error setting modifier parameters: {str(e)}")
    -                    # Fall back to compatibility mode
    -                    try:
    -                        # Directly iterate through modifier properties and set values
    -                        for socket in input_sockets:
    -                            if "value" in socket and socket.get("type") != "NodeSocketGeometry":
    -                                socket_name = socket.get("name")
    -                                # Try to find matching socket keys
    -                                for key in geometry_modifier.keys():
    -                                    if key.startswith("Socket_"):
    -                                        # Try to set directly
    -                                        try:
    -                                            geometry_modifier[key] = socket["value"]
    -                                            print(f"Directly set {key} = {socket['value']}")
    -                                            break
    -                                        except:
    -                                            pass
    -                    except:
    -                        pass
    +                self._set_modifier_parameters(geometry_modifier, node_group, input_sockets)
     
                 return {
                     "status": "success",
    @@ -1985,6 +2457,182 @@
                 }
             except Exception as e:
                 return {"error": f"Error completing geometry node network: {str(e)}"}
    +
    +    def _get_or_create_object(self, object_name):
    +        """Get existing object or create new one"""
    +        obj = bpy.data.objects.get(object_name)
    +        if not obj:
    +            result = self._create_geometry_nodes_object(object_name)
    +            if "error" in result:
    +                return result
    +            obj = bpy.data.objects.get(object_name)
    +        return obj
    +
    +    def _setup_geometry_modifier(self, obj, object_name):
    +        """Setup geometry nodes modifier"""
    +        geometry_modifier = next((mod for mod in obj.modifiers if mod.type == 'NODES'), None)
    +        
    +        if geometry_modifier and geometry_modifier.node_group:
    +            old_node_group = geometry_modifier.node_group
    +            geometry_modifier.node_group = None
    +            if old_node_group:
    +                bpy.data.node_groups.remove(old_node_group)
    +
    +        if not geometry_modifier:
    +            geometry_modifier = obj.modifiers.new(name="GeometryNodes", type='NODES')
    +
    +        node_group = bpy.data.node_groups.new(name=f"{object_name}_geometry", type='GeometryNodeTree')
    +        if IS_BLENDER_4:
    +            node_group.is_modifier = True
    +        
    +        geometry_modifier.node_group = node_group
    +        self._setup_node_group_interface(node_group, None)
    +        return geometry_modifier
    +
    +    def _create_default_nodes(self, node_group, nodes):
    +        """Create default input/output nodes if not present"""
    +        has_input = any(node_data.get("type") in ['NodeGroupInput', 'GroupInput'] for node_data in nodes)
    +        has_output = any(node_data.get("type") in ['NodeGroupOutput', 'GroupOutput'] for node_data in nodes)
    +        
    +        input_node = None
    +        output_node = None
    +        
    +        if not has_input:
    +            input_node = node_group.nodes.new('NodeGroupInput')
    +            input_node.location = (-300, 0)
    +            
    +        if not has_output:
    +            output_node = node_group.nodes.new('NodeGroupOutput')
    +            output_node.location = (500, 0)
    +            
    +        return input_node, output_node
    +
    +    def _create_nodes(self, node_group, nodes):
    +        """Create nodes from definitions"""
    +        created_nodes = []
    +        
    +        for node_data in nodes:
    +            try:
    +                node = node_group.nodes.new(node_data["type"])
    +                
    +                if "location" in node_data:
    +                    node.location = node_data["location"]
    +                if "label" in node_data:
    +                    node.label = node_data["label"]
    +                    
    +                self._set_node_properties(node, node_data.get("properties", {}))
    +                self._set_node_inputs(node, node_data.get("inputs", {}))
    +                
    +                created_nodes.append({
    +                    "name": node.name,
    +                    "type": node.type,
    +                    "location": [node.location.x, node.location.y]
    +                })
    +            except Exception as e:
    +                raise Exception(f"Error creating node {node_data['type']}: {str(e)}")
    +                
    +        return created_nodes
    +
    +    def _set_node_properties(self, node, properties):
    +        """Set node properties"""
    +        for prop_name, prop_value in properties.items():
    +            if hasattr(node, prop_name):
    +                try:
    +                    setattr(node, prop_name, prop_value)
    +                except Exception as e:
    +                    print(f"Error setting property {prop_name}: {e}")
    +
    +    def _set_node_inputs(self, node, inputs):
    +        """Set node input values"""
    +        for input_name, input_value in inputs.items():
    +            for input_socket in node.inputs:
    +                if input_socket.name == input_name and hasattr(input_socket, 'default_value'):
    +                    try:
    +                        if hasattr(input_socket.default_value, '__len__'):
    +                            for i, val in enumerate(input_value):
    +                                if i < len(input_socket.default_value):
    +                                    input_socket.default_value[i] = val
    +                        else:
    +                            input_socket.default_value = input_value
    +                    except Exception as e:
    +                        print(f"Error setting input value: {e}")
    +
    +    def _find_node(self, node_group, node_id, created_nodes, input_node, output_node):
    +        """Find node by ID (name, index, or special identifier)"""
    +        if node_id == "input":
    +            return input_node
    +        elif node_id == "output":
    +            return output_node
    +        elif isinstance(node_id, int) and 0 <= node_id < len(created_nodes):
    +            return node_group.nodes.get(created_nodes[node_id]["name"])
    +        else:
    +            return node_group.nodes.get(node_id)
    +
    +    def _find_socket(self, node, socket_id, is_output=True):
    +        """Find socket by ID (name or index)"""
    +        sockets = node.outputs if is_output else node.inputs
    +        
    +        if isinstance(socket_id, int) and 0 <= socket_id < len(sockets):
    +            return sockets[socket_id]
    +        else:
    +            return next((s for s in sockets if s.name == socket_id), None)
    +
    +    def _create_links(self, node_group, links, created_nodes, input_node, output_node):
    +        """Create links between nodes"""
    +        created_links = []
    +        
    +        for link_data in links:
    +            try:
    +                from_node = self._find_node(node_group, link_data["from_node"], created_nodes, input_node, output_node)
    +                to_node = self._find_node(node_group, link_data["to_node"], created_nodes, input_node, output_node)
    +                
    +                if not from_node:
    +                    raise Exception(f"Could not find source node: {link_data['from_node']}")
    +                if not to_node:
    +                    raise Exception(f"Could not find target node: {link_data['to_node']}")
    +                
    +                from_socket = self._find_socket(from_node, link_data["from_socket"], True)
    +                to_socket = self._find_socket(to_node, link_data["to_socket"], False)
    +                
    +                if not from_socket:
    +                    raise Exception(f"Could not find source socket: {link_data['from_socket']}")
    +                if not to_socket:
    +                    raise Exception(f"Could not find target socket: {link_data['to_socket']}")
    +                
    +                node_group.links.new(from_socket, to_socket)
    +                
    +                created_links.append({
    +                    "from_node": from_node.name,
    +                    "from_socket": from_socket.name,
    +                    "to_node": to_node.name,
    +                    "to_socket": to_socket.name
    +                })
    +            except Exception as e:
    +                raise Exception(f"Error creating link: {str(e)}")
    +                
    +        return created_links
    +
    +    def _set_modifier_parameters(self, geometry_modifier, node_group, input_sockets):
    +        """Set modifier parameters from input socket values"""
    +        try:
    +            group_input_node = next((node for node in node_group.nodes if node.type == 'GROUP_INPUT'), None)
    +            if not group_input_node:
    +                group_input_node = node_group.nodes.new('NodeGroupInput')
    +
    +            socket_dict = {output.name: i for i, output in enumerate(group_input_node.outputs)}
    +
    +            for socket in input_sockets:
    +                if "value" in socket and socket.get("type") != "NodeSocketGeometry":
    +                    socket_name = socket.get("name")
    +                    if socket_name in socket_dict:
    +                        socket_key = f"Socket_{socket_dict[socket_name]}"
    +                        try:
    +                            if socket_key in geometry_modifier:
    +                                geometry_modifier[socket_key] = socket["value"]
    +                        except Exception as e:
    +                            print(f"Error setting modifier parameter {socket_key}: {e}")
    +        except Exception as e:
    +            print(f"Error setting modifier parameters: {e}")
     
         def _create_geometry_nodes_object(self, object_name):
             """Create a base object and add a geometry nodes modifier
    @@ -2112,96 +2760,64 @@

    The method references a helper method _create_geometry_nodes_object() that
    appears to be missing from the implementation. This method is critical for
    creating a new object with geometry nodes when the specified object doesn't
    exist.

    addon.py [1700-1718]

     def complete_geometry_node(self, object_name, nodes, links, input_sockets=None):
         """Complete geometry node network creation
     
         Args:
             object_name: Object name
             nodes: List of node definitions
             links: List of node connections
             input_sockets: Node group input interface definitions [{"name": "Name", "type": "Type", "value": "DefaultValue"}]
     
         Returns:
             dict: Dictionary containing operation status and related information
         """
         try:
             obj = bpy.data.objects.get(object_name)
             if not obj:
    -            result = self._create_geometry_nodes_object(object_name)
    -            if "error" in result:
    -                return result
    +            # Create a new mesh object
    +            mesh = bpy.data.meshes.new(object_name)
    +            obj = bpy.data.objects.new(object_name, mesh)
    +            bpy.context.collection.objects.link(obj)
    +            # Add geometry nodes modifier
    +            modifier = obj.modifiers.new(name="GeometryNodes", type='NODES')
    +            # Create a new node group
    +            node_group = bpy.data.node_groups.new(name=f"{object_name}_geometry", type='GeometryNodeTree')
    +            modifier.node_group = node_group
                 obj = bpy.data.objects.get(object_name)
    • Apply this suggestion
    Suggestion importance[1-10]: 9

    __

    Why: The code references a helper method _create_geometry_nodes_object() that doesn't exist, which would cause a runtime error. The suggestion provides a proper implementation for creating a new object with geometry nodes.

    High
    Missing handler method
    Suggestion Impact:The commit addresses the root problem by adding/including an actual `get_node_info` implementation (and related helper methods) in the GeometryNodeInfo region, so the handler reference will no longer fail at runtime. It did not implement the exact suggested `hasattr(...)?...:...` fallback in the handler map; instead it ensured `get_node_info` exists.

    code diff:

         # region GeometryNodeInfo
         def get_node_info(self, output_format='text', include_details=False, node_type_name=None):
    -        """Get node type information
    -
    -        Args:
    -            output_format: Output format ('text', 'json')
    -            include_details: Whether to include detailed information (properties and sockets)
    -            node_type_name: Node type name, can be a single string, comma-separated multiple node names, or a list of strings
    -
    -        Returns:
    -            str: Returns node information in different formats based on output_format:
    -            - 'text': String, each line as "TypeName:Description" and detailed information (if include_details is True)
    -            - 'json': JSON formatted string
    -        """
    -        # Handle include_details type conversion
    +        """Get node type information"""
             try:
    -            if isinstance(include_details, str):
    -                include_details_lower = include_details.lower()
    -                include_details = include_details_lower == 'true' or include_details_lower == 'yes' or include_details_lower == '1'
    -
    -            # Handle node_type_name parameter, ensuring it's a list
    -            node_type_names = []
    -            if node_type_name is not None:
    -                # If it's a string, check if it's a comma-separated list
    -                if isinstance(node_type_name, str):
    -                    # Split by comma and strip whitespace
    -                    node_type_names = [name.strip() for name in node_type_name.split(',') if name.strip()]
    -                # If it's already a list, use it directly
    -                elif isinstance(node_type_name, list):
    -                    node_type_names = node_type_name
    -                else:
    -                    raise ValueError(f"node_type_name must be a string or a list, received: {type(node_type_name)}")
    -
    -                # Ensure all elements in the list are strings
    -                if not all(isinstance(item, str) for item in node_type_names):
    -                    raise ValueError(f"All elements in node_type_name list must be strings")
    -
    -                print(f"Processing node type names: {node_type_names} (from: {node_type_name})")
    -
    -            # Handle output_format parameter
    +            include_details = self._parse_bool_param(include_details)
    +            node_type_names = self._parse_node_type_names(node_type_name)
    +            
                 if output_format not in ('text', 'json'):
                     raise ValueError(f"Unsupported output format: {output_format}, please use 'text' or 'json'")
     
                 self._register_node_info_cache()
    -
    -            # Get all node information
                 node_infos = self._get_nodes_from_cache_or_collect()
    -
    -            # If specific node type names are specified, return only those nodes' information
    +            
                 if node_type_names:
    -                # Find target nodes
                     target_nodes = [node for node in node_infos if node.name in node_type_names]
    -
    -                # If no target nodes are found
                     if not target_nodes:
    -                    if len(node_type_names) == 1:
    -                        return f"Node {node_type_names[0]} does not exist or cannot be created"
    -                    else:
    -                        return f"Specified nodes do not exist or cannot be created: {', '.join(node_type_names)}"
    -
    -                # Return node information based on output_format
    -                if output_format == 'text':
    -                    if len(target_nodes) == 1:
    -                        return self._format_single_node_text(target_nodes[0], include_details)
    -                    else:
    -                        if include_details:
    -                            return "\n\n".join(self._format_node_text(node, True) for node in target_nodes)
    -                        else:
    -                            return '\n'.join(self._format_node_text(node, False) for node in target_nodes)
    -                else:  # 'json'
    -                    node_dicts = [self._node_to_dict(node, include_details) for node in target_nodes]
    -                    if len(node_dicts) == 1:
    -                        return json.dumps(node_dicts[0], ensure_ascii=False, indent=2, default=str)
    -                    else:
    -                        return json.dumps(node_dicts, ensure_ascii=False, indent=2, default=str)
    -
    -            # Return information about all nodes based on output_format
    -            if output_format == 'text':
    -                if include_details:
    -                    return "\n\n".join(self._format_node_text(node, True) for node in node_infos)
    -                else:
    -                    return '\n'.join(self._format_node_text(node, False) for node in node_infos)
    -
    -            else:  # 'json'
    -                node_dicts = [self._node_to_dict(node, include_details) for node in node_infos]
    -                return json.dumps(node_dicts, ensure_ascii=False, indent=2, default=str)
    +                    missing = ', '.join(node_type_names)
    +                    return f"Node{'s' if len(node_type_names) > 1 else ''} {missing} do{'es' if len(node_type_names) == 1 else ''} not exist or cannot be created"
    +                node_infos = target_nodes
    +
    +            return self._format_output(node_infos, output_format, include_details)
     
             except Exception as e:
                 print(f"Error getting node information: {str(e)}")
                 import traceback
                 traceback.print_exc()
                 return f"Error getting node information: {str(e)}"
    +
    +    def _parse_bool_param(self, param):
    +        """Parse boolean parameter from string or bool"""
    +        if isinstance(param, str):
    +            return param.lower() in ('true', 'yes', '1')
    +        return bool(param)
    +
    +    def _parse_node_type_names(self, node_type_name):
    +        """Parse node type names parameter"""
    +        if node_type_name is None:
    +            return []
    +        
    +        if isinstance(node_type_name, str):
    +            return [name.strip() for name in node_type_name.split(',') if name.strip()]
    +        elif isinstance(node_type_name, list):
    +            if not all(isinstance(item, str) for item in node_type_name):
    +                raise ValueError("All elements in node_type_name list must be strings")
    +            return node_type_name
    +        else:
    +            raise ValueError(f"node_type_name must be a string or a list, received: {type(node_type_name)}")
    +
    +    def _format_output(self, node_infos, output_format, include_details):
    +        """Format node information output"""
    +        if output_format == 'json':
    +            node_dicts = [self._node_to_dict(node, include_details) for node in node_infos]
    +            result = node_dicts[0] if len(node_dicts) == 1 else node_dicts
    +            return json.dumps(result, ensure_ascii=False, indent=2, default=str)
    +        else:  # text
    +            if len(node_infos) == 1:
    +                return self._format_single_node_text(node_infos[0], include_details)
    +            else:
    +                separator = "\n\n" if include_details else "\n"
    +                return separator.join(self._format_node_text(node, include_details) for node in node_infos)
     

    The code references a method get_node_info that is used as a handler for
    geometry nodes functionality, but this method is not defined in the provided
    code. This will cause a runtime error when the handler is called.

    addon.py [313-319]

     # Add geometry nodes handlers only if enabled
     if bpy.context.scene.blendermcp_use_geometry_nodes:
         geometry_nodes_handlers = {
    -        "get_node_info": self.get_node_info,
    +        "get_node_info": self.get_node_info if hasattr(self, 'get_node_info') else self.get_simple_info,
             "complete_geometry_node": self.complete_geometry_node,
         }
         handlers.update(geometry_nodes_handlers)
    • Apply this suggestion
    Suggestion importance[1-10]: 8

    __

    Why: The code references a get_node_info method that isn't defined, which would cause a runtime error when the handler is called. The suggestion adds a fallback to an existing method to prevent crashes.

    Medium
    Missing status method

    The code calls a method get_geometry_nodes_status() that is not defined in the
    provided code. This will cause a runtime error when the handler is called.

    addon.py [276-278]

     # Add a handler for checking GeometryNodes status
     if cmd_type == "get_geometry_nodes_status":
    -    return {"status": "success", "result": self.get_geometry_nodes_status()}
    +    return {"status": "success", "result": {"enabled": bpy.context.scene.blendermcp_use_geometry_nodes, 
    +                                           "message": "Geometry Nodes integration is enabled and ready to use." if bpy.context.scene.blendermcp_use_geometry_nodes else 
    +                                           "Geometry Nodes integration is currently disabled."}}
    • Apply this suggestion
    Suggestion importance[1-10]: 8

    __

    Why: The code calls a get_geometry_nodes_status() method that isn't defined, which would cause a runtime error. The suggestion implements the missing method with appropriate status information.

    Medium
    Handle empty string input

    The code doesn't handle the case where an empty string is passed as
    node_type_name. When an empty string is passed, the function will still try to
    use it as a node type name, which will likely cause errors in the Blender
    command. You should treat an empty string the same as None or convert it to an
    empty list.

    src/blender_mcp/server.py [1007-1019]

     # Process node_type_name parameter, support comma-separated string
    -if node_type_name is not None:
    +if node_type_name is not None and node_type_name != '':
         # If it's a string, check if it's a comma-separated list
         if isinstance(node_type_name, str):
             # Check if it's a multi-node name with commas
             if ',' in node_type_name:
                 # Split by comma and remove whitespace
                 node_type_name = [name.strip() for name in node_type_name.split(',') if name.strip()]
         
         # Make sure parameter type is correct
         if not isinstance(node_type_name, (str, list)):
             return f"Error: node_type_name parameter must be a string or list of strings, received: {type(node_type_name)}"
    +elif node_type_name == '':
    +    node_type_name = []

    [To ensure code accuracy, apply this suggestion manually]

    Suggestion importance[1-10]: 7

    __

    Why: This suggestion addresses a potential bug where empty strings in node_type_name would be treated differently than None, potentially causing errors in Blender. The fix properly handles empty strings by converting them to empty lists, preventing potential runtime errors.

    Medium
    • Update

    @coderabbitai coderabbitai Bot left a comment

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Actionable comments posted: 0

    🧹 Nitpick comments (6)
    README.md (1)

    171-179: Helpful example commands
    These commands illustrate the geometry node module usage well and will benefit users experimenting with procedural modeling.

    For consistency with the rest of the document, consider aligning the bullet points' style (e.g., capitalization, punctuation).

    src/blender_mcp/server.py (5)

    9-9: Remove the unused Union import
    Static analysis indicates Union is never used. Removing it avoids clutter and adheres to best practices.

    -from typing import AsyncIterator, Dict, Any, List, Union
    +from typing import AsyncIterator, Dict, Any, List
    🧰 Tools
    🪛 Ruff (0.8.2)

    9-9: typing.Union imported but unused

    Remove unused import: typing.Union

    (F401)


    208-221: Use of globals for geometry node status
    Using _geometry_nodes_enabled across the code is convenient for quick checks. However, consider wrapping these flags into a dedicated class or configuration object for better maintainability if this project grows.


    951-1044: Nested condition simplification
    This function is generally solid. Consider combining nested if statements (lines 1009-1011) into a single condition to reduce complexity.

    Example diff:

    -if isinstance(node_type_name, str):
    -    if ',' in node_type_name:
    -        node_type_name = [name.strip() for name in node_type_name.split(',') if name.strip()]
    +if isinstance(node_type_name, str) and ',' in node_type_name:
    +    node_type_name = [name.strip() for name in node_type_name.split(',') if name.strip()]
    🧰 Tools
    🪛 Ruff (0.8.2)

    1009-1011: Use a single if statement instead of nested if statements

    (SIM102)


    1045-1145: Large single method
    complete_geometry_node is quite extensive and handles many steps in a single function. Splitting it into smaller helper methods (e.g., node creation, link creation, and modifier parameter setup) enhances readability and maintainability.


    1146-1162: Remove unused local variable
    The variable enabled is assigned but never utilized, based on static analysis. You can omit it.

    -    enabled = result.get("enabled", False)
         message = result.get("message", "")
    🧰 Tools
    🪛 Ruff (0.8.2)

    1155-1155: Local variable enabled is assigned to but never used

    Remove assignment to unused variable enabled

    (F841)

    📜 Review details

    Configuration used: CodeRabbit UI
    Review profile: CHILL
    Plan: Pro

    📥 Commits

    Reviewing files that changed from the base of the PR and between 9b3b327 and 7370bc5.

    📒 Files selected for processing (3)
    • .gitignore (1 hunks)
    • README.md (2 hunks)
    • src/blender_mcp/server.py (5 hunks)
    🧰 Additional context used
    🧬 Code Definitions (1)
    src/blender_mcp/server.py (2)
    addon.py (3)
    • get_node_info (2114-2204)
    • complete_geometry_node (1700-1987)
    • get_geometry_nodes_status (2595-2622)
    main.py (1)
    • main (3-5)
    🪛 Ruff (0.8.2)
    src/blender_mcp/server.py

    9-9: typing.Union imported but unused

    Remove unused import: typing.Union

    (F401)


    1009-1011: Use a single if statement instead of nested if statements

    (SIM102)


    1155-1155: Local variable enabled is assigned to but never used

    Remove assignment to unused variable enabled

    (F841)

    🔇 Additional comments (5)
    .gitignore (1)

    11-12: Ignore JetBrains .idea folder
    No concerns with ignoring JetBrains IDE metadata. This is a standard entry for better version control hygiene.

    README.md (2)

    154-154: New geometry node module heading
    This heading clearly communicates the new geometry node functionality. Good addition.


    185-185: No issues with spacing
    This line appears to be a blank or spacing line. No further concerns.

    src/blender_mcp/server.py (2)

    204-204: Global _geometry_nodes_enabled variable declaration
    Initialization looks fine. No immediate concerns regarding usage.


    1170-1170: Entry point inclusion
    Calling main() on script execution is standard. No concerns here.

    @ahujasid

    ahujasid commented Apr 3, 2025

    Copy link
    Copy Markdown
    Owner

    @duqich curious what this would add over the existing "execute_code" function, since it can run code for anything in Blender. Is this not possible?

    @duqich

    duqich commented Apr 4, 2025

    Copy link
    Copy Markdown
    Author

    @ahujasid
    The Geometry Nodes editor's primary purpose is to create a visual, node-based workflow for model construction that clearly shows the building process. Through modifier parameters and node adjustments, it becomes an adjustable, procedural model.

    While execute_code can indeed perform any operation through the Blender Python API, including creating geometry node modifiers, LLMs face significant challenges in automatically constructing geometry node networks for two main reasons:

    • 1.Version Compatibility: LLMs tend to generate code based on Blender 3.x syntax, which often fails to run properly in Blender 4.x.
    • 2.Limited Training Data: LLMs only have a general understanding of certain nodes and their functions.

    My implementation addresses these limitations by:

    • Directly querying the current Blender version's available nodes
    • Retrieving comprehensive information about node sockets and properties
    • Providing LLMs with accurate, version-specific node descriptions

    Beyond the core functionality, I've also:

    • Designed standardized data structures for node creation and connections with default value support
    • Implemented it as a toggleable feature in the BlenderMCP panel, similar to PolyHaven and Hyper3D
    • Created workflow prompts to guide LLMs through the node network construction process

    This enables LLMs to construct geometry node networks more reliably. Additionally, if errors occur, the agent can:

    • Inspect why specific node connections failed
    • Query available alternative nodes
    • Suggest corrections based on actual node compatibility

    This structured approach significantly improves the reliability and usability of procedural modeling through LLM-driven geometry nodes.

    @coderabbitai coderabbitai Bot left a comment

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Actionable comments posted: 1

    🧹 Nitpick comments (11)
    src/blender_mcp/server.py (3)

    9-9: Remove unused imports.

    The Union and List imports from the typing module are never used in this file.

    -from typing import AsyncIterator, Dict, Any, List, Union
    +from typing import AsyncIterator, Dict, Any
    🧰 Tools
    🪛 Ruff (0.8.2)

    9-9: typing.List imported but unused

    Remove unused import

    (F401)


    9-9: typing.Union imported but unused

    Remove unused import

    (F401)


    766-859: Simplify nested conditional logic.

    The function implementation looks great overall, but the nested if statements for processing node_type_name parameter could be simplified.

    -            # Process node_type_name parameter, support comma-separated string
    -            if node_type_name is not None:
    -                # If it's a string, check if it's a comma-separated list
    -                if isinstance(node_type_name, str):
    -                    # Check if it's a multi-node name with commas
    -                    if ',' in node_type_name:
    -                        # Split by comma and remove whitespace
    -                        node_type_name = [name.strip() for name in node_type_name.split(',') if name.strip()]
    +            # Process node_type_name parameter, support comma-separated string
    +            if node_type_name is not None and isinstance(node_type_name, str) and ',' in node_type_name:
    +                # Split by comma and remove whitespace
    +                node_type_name = [name.strip() for name in node_type_name.split(',') if name.strip()]
    🧰 Tools
    🪛 Ruff (0.8.2)

    824-826: Use a single if statement instead of nested if statements

    (SIM102)


    961-976: Remove unused variable.

    The enabled variable is assigned but never used in this function.

    -        enabled = result.get("enabled", False)
             message = result.get("message", "")
    🧰 Tools
    🪛 Ruff (0.8.2)

    970-970: Local variable enabled is assigned to but never used

    Remove assignment to unused variable enabled

    (F841)

    addon.py (8)

    15-16: Remove unused Optional import.

    According to static analysis, typing.Optional is not used anywhere in the file. Removing it helps keep the imports concise and avoid confusion.

    Apply this diff to remove the unused import:

    -from typing import List, Dict, Union, Any, Optional, Tuple
    +from typing import List, Dict, Union, Any, Tuple
    🧰 Tools
    🪛 Ruff (0.8.2)

    16-16: typing.Optional imported but unused

    Remove unused import: typing.Optional

    (F401)


    30-31: Add clarifying docstring around IS_BLENDER_4.

    Although the code is self-explanatory, adding a short comment or docstring clarifies the purpose of checking the Blender major version. This helps new contributors or maintainers understand why certain conditionals exist for Blender 4.0+ compatibility.


    105-105: Consider removing or clarifying commented placeholders if any.

    It looks like line 105 is marked changed with no visible content in the snippet. If this was merely a placeholder or leftover commentary, please ensure it remains purposeful or remove it to avoid confusion.


    135-138: Adopt contextlib.suppress(Exception) for silent exception handling.

    Multiple lines rely on a try-except-pass pattern (lines 135-138, 560-563, 618-621, 1726-1728). Using with contextlib.suppress(Exception): is more pythonic and avoids swallowing system-level exceptions inadvertently. It reads clearer and prevents repeated pass blocks.

    Example refactor for lines 135-138:

    -try:
    -    self.socket.close()
    -except:
    -    pass
    +import contextlib
    +with contextlib.suppress(Exception):
    +    self.socket.close()

    Also applies to: 560-563, 618-621, 1726-1728

    🧰 Tools
    🪛 Ruff (0.8.2)

    135-138: Use contextlib.suppress(Exception) instead of try-except-pass

    Replace with contextlib.suppress(Exception)

    (SIM105)


    137-137: Do not use bare except

    (E722)


    573-573: Remove extraneous f-prefix from strings without placeholders.

    Lines 573, 624, 1901, 2150, 2188, and 2264 contain f-strings with no placeholders. Removing the f prefix avoids confusion and minor overhead.

    -f"Requested resolution or format not available for this HDRI"
    +"Requested resolution or format not available for this HDRI"

    Also applies to: 624-624, 1901-1901, 2150-2150, 2188-2188, 2264-2264

    🧰 Tools
    🪛 Ruff (0.8.2)

    573-573: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    583-584: Consider merging nested if conditions into a single statement.

    At lines 583-584, the nested checks can be simplified to one condition, as suggested by static analysis (SIM102), improving readability.

    🧰 Tools
    🪛 Ruff (0.8.2)

    583-584: Use a single if statement instead of nested if statements

    (SIM102)


    1648-1648: Avoid assigning to link if unused.

    Line 1648 sets link = node_group.links.new(from_socket, to_socket) but never references link. This variable assignment is unnecessary; you can omit the variable altogether.

    -link = node_group.links.new(from_socket, to_socket)
    +node_group.links.new(from_socket, to_socket)
    🧰 Tools
    🪛 Ruff (0.8.2)

    1648-1648: Local variable link is assigned to but never used

    Remove assignment to unused variable link

    (F841)


    1706-1706: Use key in dict instead of key in dict.keys().

    At lines 1706 and 1719, removing .keys() is clearer, as if key in my_dict: is pythonic and avoids extra overhead.

    -if socket_name in socket_dict.keys():
    +if socket_name in socket_dict:

    Also applies to: 1719-1719

    🧰 Tools
    🪛 Ruff (0.8.2)

    1706-1706: Use key in dict instead of key in dict.keys()

    Remove .keys()

    (SIM118)

    📜 Review details

    Configuration used: CodeRabbit UI
    Review profile: CHILL
    Plan: Pro

    📥 Commits

    Reviewing files that changed from the base of the PR and between 7370bc5 and 3727b8f.

    📒 Files selected for processing (2)
    • addon.py (45 hunks)
    • src/blender_mcp/server.py (5 hunks)
    🧰 Additional context used
    🧬 Code Definitions (1)
    addon.py (1)
    src/blender_mcp/server.py (3)
    • get_geometry_nodes_status (962-976)
    • get_node_info (767-858)
    • complete_geometry_node (861-959)
    🪛 Ruff (0.8.2)
    src/blender_mcp/server.py

    9-9: typing.List imported but unused

    Remove unused import

    (F401)


    9-9: typing.Union imported but unused

    Remove unused import

    (F401)


    824-826: Use a single if statement instead of nested if statements

    (SIM102)


    970-970: Local variable enabled is assigned to but never used

    Remove assignment to unused variable enabled

    (F841)

    addon.py

    16-16: typing.Optional imported but unused

    Remove unused import: typing.Optional

    (F401)


    135-138: Use contextlib.suppress(Exception) instead of try-except-pass

    Replace with contextlib.suppress(Exception)

    (SIM105)


    137-137: Do not use bare except

    (E722)


    560-563: Use contextlib.suppress(Exception) instead of try-except-pass

    (SIM105)


    562-562: Do not use bare except

    (E722)


    573-573: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    583-584: Use a single if statement instead of nested if statements

    (SIM102)


    618-621: Use contextlib.suppress(Exception) instead of try-except-pass

    Replace with contextlib.suppress(Exception)

    (SIM105)


    620-620: Do not use bare except

    (E722)


    624-624: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    1648-1648: Local variable link is assigned to but never used

    Remove assignment to unused variable link

    (F841)


    1706-1706: Use key in dict instead of key in dict.keys()

    Remove .keys()

    (SIM118)


    1719-1719: Use key in dict instead of key in dict.keys()

    Remove .keys()

    (SIM118)


    1726-1726: Do not use bare except

    (E722)


    1728-1728: Do not use bare except

    (E722)


    1901-1901: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    1981-1981: Import socket from line 7 shadowed by loop variable

    (F402)


    1993-1993: Do not use bare except

    (E722)


    2092-2092: Local variable e is assigned to but never used

    Remove assignment to unused variable e

    (F841)


    2150-2150: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    2188-2188: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    2264-2264: f-string without any placeholders

    Remove extraneous f prefix

    (F541)

    🔇 Additional comments (15)
    src/blender_mcp/server.py (4)

    204-204: LGTM: Properly tracked geometry nodes feature status.

    The global variable is a good approach to keep track of whether the geometry nodes feature is enabled.


    208-221: Feature status tracking implementation looks good.

    The code now properly checks and updates the geometry nodes status when establishing a Blender connection.


    739-752: Well-structured documentation for geometry nodes workflow.

    The asset creation strategy documentation provides clear guidance on using the geometry nodes feature with a logical step-by-step approach.


    860-959: Well-implemented geometry node network creation function.

    The complete_geometry_node function is very well documented with clear parameter specifications and usage examples. The implementation checks for feature availability before proceeding and properly handles the node creation process.

    addon.py (11)

    34-42: Data class NodeDefinition appears correct.

    The usage of @dataclass for NodeDefinition is clean and clear, storing node parameters in a structured way. The class docstring is concise, and the typed fields help ensure clarity.


    44-51: Data class NodeLink is well-defined.

    Defining connections with explicit fields for node/socket references is a good approach. This fosters clear modeling of links between node sockets. No issues found here.


    53-61: Data class GeometryNodeNetwork is coherent.

    This class straightforwardly groups nodes, links, and socket definitions. The approach is consistent with the other data classes, making the code maintainable and modular.


    63-73: Data class SocketInfo is well-structured.

    Using typed fields and capturing default_value is helpful for robust node creation. The docstring also clearly explains each field.


    75-84: Data class PropertyInfo is properly implemented.

    Enum options and property details are captured in a structured format, which is useful for introspection and dynamic UI building.


    86-94: Data class NodeInfo aligns well with the existing data classes.

    All basic node metadata (description, inputs, outputs, properties) is included. This design fosters a consistent approach to geometry node information retrieval.


    263-279: Check for redundant entries in handlers.

    New geometry node handlers at lines 268-269 partially duplicate the dictionary entry in lines 278-279. Ensure the logic in _execute_command_internal remains free of collisions or repeated key usage (e.g., 'get_geometry_nodes_status' is assigned multiple times).


    291-299: Hyper3D conditionally added handlers block.

    This conditional registration approach is consistent with PolyHaven’s logic above. Looks good. Just be sure you handle potential collisions in the handlers dictionary (no issues seem present right now).


    300-306: Conditional geometry node handlers registration.

    Registering geometry node commands only when blendermcp_use_geometry_nodes is enabled is a clean approach. This ensures you don't load or expose geometry node functionalities unnecessarily. Implementation looks correct.


    1452-1741: Review concurrency with main-thread scheduling for geometry node creation.

    This method, complete_geometry_node, delegates node creation to the main thread via bpy.app.timers.register(...). Ensure that any shared state (e.g., node group references) is carefully guarded or remains static to prevent race conditions if other timers or threads also manipulate these objects. The logic for building node networks and linking them looks correct otherwise.

    Would you like to run additional multi-thread concurrency tests to confirm that no data races occur when multiple geometry node operations arrive in quick succession?

    🧰 Tools
    🪛 Ruff (0.8.2)

    1648-1648: Local variable link is assigned to but never used

    Remove assignment to unused variable link

    (F841)


    1706-1706: Use key in dict instead of key in dict.keys()

    Remove .keys()

    (SIM118)


    1719-1719: Use key in dict instead of key in dict.keys()

    Remove .keys()

    (SIM118)


    1726-1726: Do not use bare except

    (E722)


    1728-1728: Do not use bare except

    (E722)


    1867-2350: get_node_info and supporting private methods are well-structured.

    The multi-step approach to gather node details, property data, and caching is thorough. It fosters quick lookups without frequently hitting Blender’s heavy node system. Overall, the logic is correct, though watch out for performance overhead if the node list becomes very large.

    🧰 Tools
    🪛 Ruff (0.8.2)

    1901-1901: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    1981-1981: Import socket from line 7 shadowed by loop variable

    (F402)


    1993-1993: Do not use bare except

    (E722)


    2092-2092: Local variable e is assigned to but never used

    Remove assignment to unused variable e

    (F841)


    2150-2150: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    2188-2188: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    2264-2264: f-string without any placeholders

    Remove extraneous f prefix

    (F541)

    Comment thread addon.py
    @duqich

    duqich commented Apr 4, 2025

    Copy link
    Copy Markdown
    Author

    @ahujasid
    simple example:
    prompt: Use geometry nodes to create points distributed on a sphere surface with exposed radius and density parameters...
    blender
    cursor

    @ahujasid

    Copy link
    Copy Markdown
    Owner

    Keeping this in the backlog for now, it's a good feature, but probably a bit too specific at this stage

    @CreateTheImaginable

    Copy link
    Copy Markdown

    Keeping this in the backlog for now, it's a good feature, but probably a bit too specific at this stage

    Hello @ahujasid, Geometry Nodes are one of the really important features of Blender. Their procedural nature can help the LLMs talk to and understand Blender. This Pull Request code from @duqich seems to have an UI option in the Blender AddOn to turn off Geometry Nodes if they and not needed.

    Can we revisit this Pull Request and get it merged into the main branch?

    @ahujasid

    ahujasid commented Jan 2, 2026

    Copy link
    Copy Markdown
    Owner

    @duqich if you can simplify the code and solve the conflicts i can merge

    @coderabbitai coderabbitai Bot left a comment

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Actionable comments posted: 3

    Caution

    Some comments are outside the diff and can’t be posted inline due to platform limitations.

    ⚠️ Outside diff range comments (1)
    src/blender_mcp/server.py (1)

    882-884: Bug: URL validation uses wrong variable.

    Line 883 validates input_image_paths instead of input_image_urls, causing incorrect validation when URLs are provided.

    Proposed fix
         elif input_image_urls is not None:
    -        if not all(urlparse(i) for i in input_image_paths):
    +        if not all(urlparse(i).scheme for i in input_image_urls):
                 return "Error: not all image URLs are valid!"
             images = input_image_urls.copy()

    Note: Also added .scheme check since urlparse() always returns a ParseResult (truthy), making the original validation ineffective.

    🤖 Fix all issues with AI agents
    In @src/blender_mcp/server.py:
    - Around line 686-691: The telemetry decorator on get_sketchfab_model_preview is
    using the wrong tool name; update the @telemetry_tool(...) argument to match the
    function name (e.g., change "download_sketchfab_model" to
    "get_sketchfab_model_preview") so the telemetry identifier aligns with the
    get_sketchfab_model_preview tool and decorator.
    - Around line 729-734: The download_sketchfab_model tool is missing the
    @telemetry_tool decorator, causing inconsistent telemetry; add the
    @telemetry_tool decorator directly above the existing @mcp.tool() on the
    download_sketchfab_model function and ensure telemetry_tool is
    imported/available in the module (matching how other tools are decorated) so
    telemetry events are recorded consistently for download_sketchfab_model.
    - Around line 304-328: The temp file created for the Blender screenshot
    (temp_path) is not guaranteed to be removed if reading the file or any later
    step raises; wrap the screenshot creation/reading/delete sequence in a
    try/finally (or use a context manager surrounding the lifecycle) so that after
    calling get_blender_connection() and
    blender.send_command("get_viewport_screenshot", ...) you always attempt to
    os.remove(temp_path) in the finally block if the file exists; ensure existing
    error checks (result["error"] and existence of temp_path) remain and that
    image_bytes is set from the with open(...) read inside the try before returning
    or re-raising the exception.
    
    🧹 Nitpick comments (14)
    src/blender_mcp/telemetry.py (7)

    38-49: Silent exception swallowing loses diagnostic information.

    The broad except Exception with pass completely silences errors when reading the version. Consider logging at debug level to aid troubleshooting.

    Proposed improvement
     def get_package_version() -> str:
         """Get version from pyproject.toml"""
         try:
             pyproject_path = Path(__file__).parent.parent.parent.parent / "pyproject.toml"
             if pyproject_path.exists():
                 if tomli:
                     with open(pyproject_path, "rb") as f:
                         data = tomli.load(f)
                         return data["project"]["version"]
    -    except Exception:
    -        pass
    +    except Exception as e:
    +        logger.debug(f"Could not read version from pyproject.toml: {e}")
         return "unknown"

    113-113: Using logger.warning for routine telemetry initialization is excessive.

    This will clutter logs with warnings on every server start. Use logger.info or logger.debug for non-error operational information.

    Proposed fix
    -        logger.warning(f"Telemetry initialized (enabled={self.config.enabled}, has_supabase={HAS_SUPABASE}, customer_uuid={self._customer_uuid})")
    +        logger.info(f"Telemetry initialized (enabled={self.config.enabled}, has_supabase={HAS_SUPABASE})")

    Note: Also consider not logging the full customer_uuid even at info level for privacy reasons.


    141-163: Move successful return to else block for clearer control flow.

    The successful return customer_uuid on line 160 should be in an else block after the try, making it clearer that the fallback UUID on line 163 only applies when persistence fails.

    Proposed fix
         def _get_or_create_uuid(self) -> str:
             """Get or create anonymous customer UUID"""
             try:
                 data_dir = self._get_data_directory()
                 uuid_file = data_dir / "customer_uuid.txt"
     
                 if uuid_file.exists():
                     customer_uuid = uuid_file.read_text(encoding="utf-8").strip()
                     if customer_uuid:
                         return customer_uuid
     
                 # Create new UUID
                 customer_uuid = str(uuid.uuid4())
                 uuid_file.write_text(customer_uuid, encoding="utf-8")
     
                 # Set restrictive permissions on Unix
                 if sys.platform != "win32":
                     os.chmod(uuid_file, 0o600)
    -
    -            return customer_uuid
             except Exception as e:
                 logger.debug(f"Failed to persist UUID: {e}")
                 return str(uuid.uuid4())
    +        else:
    +            return customer_uuid

    176-184: Using logger.warning for normal telemetry events is incorrect.

    Lines 178, 181, and 184 use logger.warning for routine flow control and event recording. Warnings should be reserved for actual problems. Use logger.debug for skipped events and logger.info (or debug) for recording events.

    Proposed fix
         def record_event(
             ...
         ):
             """Record a telemetry event (non-blocking)"""
             if not self.config.enabled:
    -            logger.warning(f"Telemetry disabled, skipping event: {event_type}")
    +            logger.debug(f"Telemetry disabled, skipping event: {event_type}")
                 return
             if not HAS_SUPABASE:
    -            logger.warning(f"Supabase not available, skipping event: {event_type}")
    +            logger.debug(f"Supabase not available, skipping event: {event_type}")
                 return
     
    -        logger.warning(f"Recording telemetry event: {event_type}, tool={tool_name}")
    +        logger.debug(f"Recording telemetry event: {event_type}, tool={tool_name}")

    230-248: Creating a new Supabase client for every event is inefficient.

    _send_event instantiates a new Client per event, which adds connection overhead and may exhaust resources under load. Consider caching or reusing the client.

    Proposed approach

    Initialize the Supabase client once in __init__ (or lazily on first send) and reuse it:

    # In __init__:
    self._supabase_client: Client | None = None
    
    # In _send_event:
    def _get_supabase_client(self) -> Client:
        if self._supabase_client is None:
            from supabase import ClientOptions
            options = ClientOptions(auto_refresh_token=False, persist_session=False)
            self._supabase_client = create_client(
                self.config.supabase_url,
                self.config.supabase_anon_key,
                options=options
            )
        return self._supabase_client

    267-267: Unused variable response.

    The response from the Supabase insert is assigned but never used. Remove the assignment or use it for error checking.

    Proposed fix
    -            response = supabase.table("telemetry_events").insert(data, returning="minimal").execute()
    +            supabase.table("telemetry_events").insert(data, returning="minimal").execute()
                 logger.debug(f"Telemetry sent: {event_type}")

    218-228: Worker thread runs forever with no graceful shutdown mechanism.

    The _worker_loop runs an infinite while True loop with a blocking get(). When the process terminates, this daemon thread is abruptly killed, potentially losing queued events. Consider adding a sentinel value or timeout-based shutdown.

    Proposed approach
    # Add a shutdown sentinel
    _SHUTDOWN = object()
    
    def shutdown(self):
        """Signal the worker to stop"""
        self._queue.put(_SHUTDOWN)
        self._worker.join(timeout=5.0)
    
    def _worker_loop(self):
        while True:
            event = self._queue.get()
            if event is _SHUTDOWN:
                break
            try:
                self._send_event(event)
            except Exception as e:
                logger.debug(f"Telemetry send failed: {e}")
            finally:
                with contextlib.suppress(Exception):
                    self._queue.task_done()
    src/blender_mcp/server.py (7)

    775-775: f-string without placeholders.

    The f-string prefix is unnecessary here since there are no interpolations.

    Proposed fix
    -            output = f"Successfully imported model.\n"
    +            output = "Successfully imported model.\n"

    983-998: Missing telemetry decorator on Hunyuan3D tools.

    get_hunyuan3d_status, generate_hunyuan3d_model, poll_hunyuan_job_status, and import_generated_asset_hunyuan are missing @telemetry_tool decorators, unlike other similar tools.

    Proposed fix for get_hunyuan3d_status
    +@telemetry_tool("get_hunyuan3d_status")
     @mcp.tool()
     def get_hunyuan3d_status(ctx: Context) -> str:

    Apply similar decorators to:

    • generate_hunyuan3d_model@telemetry_tool("generate_hunyuan3d_model")
    • poll_hunyuan_job_status@telemetry_tool("poll_hunyuan_job_status")
    • import_generated_asset_hunyuan@telemetry_tool("import_generated_asset_hunyuan")

    1095-1101: Missing telemetry decorators on geometry nodes tools.

    get_node_info, complete_geometry_node, and get_geometry_nodes_status lack @telemetry_tool decorators for consistency with other tools.

    Proposed fix
    +@telemetry_tool("get_node_info")
     @mcp.tool()
     def get_node_info(
         ...
     
    +@telemetry_tool("complete_geometry_node")
     @mcp.tool()
     def complete_geometry_node(
         ...
     
    +@telemetry_tool("get_geometry_nodes_status")
     @mcp.tool()
     def get_geometry_nodes_status(ctx: Context) -> str:

    Also applies to: 1191-1198, 1293-1294


    1300-1305: Unused variable enabled.

    The enabled variable is assigned but never used. Either use it or remove the assignment.

    Proposed fix
         try:
             blender = get_blender_connection()
             result = blender.send_command("get_geometry_nodes_status")
    -        enabled = result.get("enabled", False)
             message = result.get("message", "")
     
             return message

    240-243: Bare except clause silently swallows all exceptions.

    The bare except: on line 242 catches and ignores all exceptions including KeyboardInterrupt and SystemExit. Use except Exception: at minimum.

    Proposed fix
                 try:
                     _blender_connection.disconnect()
    -            except:
    +            except Exception:
                     pass
                 _blender_connection = None

    1003-1004: Use explicit Optional type hints.

    PEP 484 prohibits implicit Optional. Use str | None or Optional[str] for clarity.

    Proposed fix
     def generate_hunyuan3d_model(
         ctx: Context,
    -    text_prompt: str = None,
    -    input_image_url: str = None
    +    text_prompt: str | None = None,
    +    input_image_url: str | None = None
     ) -> str:

    Similar fixes needed for:

    • Line 614: categories: str = Nonecategories: str | None = None
    • Line 1040: job_id: str = Nonejob_id: str | None = None
    • Line 1197: input_sockets: list = Noneinput_sockets: list | None = None

    231-235: Geometry nodes and PolyHaven status checks add latency to every connection validation.

    Every call to get_blender_connection() sends two additional round-trip commands (get_polyhaven_status and get_geometry_nodes_status), regardless of whether the connection was already validated. With 27 calls to this function across various tools, this cumulative latency could be significant. The status values are stored in globals but are unconditionally refreshed on every call rather than cached with a TTL or checked only on initial connection.

    📜 Review details

    Configuration used: defaults

    Review profile: CHILL

    Plan: Pro

    📥 Commits

    Reviewing files that changed from the base of the PR and between 3727b8f and a1e19b9.

    ⛔ Files ignored due to path filters (1)
    • uv.lock is excluded by !**/*.lock
    📒 Files selected for processing (8)
    • .gitignore
    • README.md
    • TERMS_AND_CONDITIONS.md
    • addon.py
    • pyproject.toml
    • src/blender_mcp/server.py
    • src/blender_mcp/telemetry.py
    • src/blender_mcp/telemetry_decorator.py
    🧰 Additional context used
    🧬 Code graph analysis (2)
    src/blender_mcp/telemetry_decorator.py (1)
    src/blender_mcp/telemetry.py (1)
    • record_tool_usage (286-299)
    src/blender_mcp/server.py (3)
    src/blender_mcp/telemetry.py (2)
    • record_startup (302-307)
    • get_telemetry (278-283)
    src/blender_mcp/telemetry_decorator.py (1)
    • telemetry_tool (16-65)
    addon.py (8)
    • get_sketchfab_status (1585-1645)
    • search_sketchfab_models (1647-1707)
    • get_sketchfab_model_preview (1709-1793)
    • download_sketchfab_model (1795-2016)
    • get_hunyuan3d_status (2020-2064)
    • poll_hunyuan_job_status (2306-2307)
    • import_generated_asset_hunyuan (2353-2354)
    • get_node_info (2847-2937)
    🪛 LanguageTool
    README.md

    [grammar] ~5-~5: Use a hyphen to join words.
    Context: ...Blender. This integration enables prompt assisted 3D modeling, scene creation, an...

    (QB_NEW_EN_HYPHEN)


    [grammar] ~125-~125: Use a hyphen to join words.
    Context: ..." button and paste - To use as a project specific server, create `.cursor/mcp.jso...

    (QB_NEW_EN_HYPHEN)

    TERMS_AND_CONDITIONS.md

    [uncategorized] ~19-~19: If this is a compound adjective that modifies the following noun, use a hyphen.
    Context: ...ts and text inputs** you provide to the AI - Generated code produced in response to your pro...

    (EN_COMPOUND_ADJECTIVE_INTERNAL)


    [style] ~171-~171: Consider replacing this word to strengthen your wording.
    Context: ... *Blender MCP is an independent project and is not affiliated with the Blender Foun...

    (AND_THAT)

    🪛 markdownlint-cli2 (0.18.1)
    README.md

    3-3: Emphasis used instead of a heading

    (MD036, no-emphasis-as-heading)

    🪛 Ruff (0.14.10)
    src/blender_mcp/telemetry_decorator.py

    28-28: Consider moving this statement to an else block

    (TRY300)


    36-36: Do not catch blind exception: Exception

    (BLE001)


    48-48: Consider moving this statement to an else block

    (TRY300)


    56-56: Do not catch blind exception: Exception

    (BLE001)

    src/blender_mcp/telemetry.py

    47-48: try-except-pass detected, consider logging the exception

    (S110)


    47-47: Do not catch blind exception: Exception

    (BLE001)


    160-160: Consider moving this statement to an else block

    (TRY300)


    161-161: Do not catch blind exception: Exception

    (BLE001)


    224-224: Do not catch blind exception: Exception

    (BLE001)


    267-267: Local variable response is assigned to but never used

    Remove assignment to unused variable response

    (F841)


    270-270: Do not catch blind exception: Exception

    (BLE001)


    314-314: Do not catch blind exception: Exception

    (BLE001)

    src/blender_mcp/server.py

    184-184: Do not catch blind exception: Exception

    (BLE001)


    262-262: Unused function argument: ctx

    (ARG001)


    270-270: Do not catch blind exception: Exception

    (BLE001)


    271-271: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    271-271: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    272-272: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    295-295: Unused function argument: ctx

    (ARG001)


    318-318: Abstract raise to an inner function

    (TRY301)


    318-318: Create your own exception

    (TRY002)


    321-321: Abstract raise to an inner function

    (TRY301)


    321-321: Create your own exception

    (TRY002)


    321-321: Avoid specifying long messages outside the exception class

    (TRY003)


    332-332: Do not catch blind exception: Exception

    (BLE001)


    333-333: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    333-333: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    334-334: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

    (B904)


    334-334: Create your own exception

    (TRY002)


    334-334: Avoid specifying long messages outside the exception class

    (TRY003)


    334-334: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    339-339: Unused function argument: ctx

    (ARG001)


    351-351: Do not catch blind exception: Exception

    (BLE001)


    352-352: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    352-352: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    353-353: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    564-564: Consider moving this statement to an else block

    (TRY300)


    565-565: Do not catch blind exception: Exception

    (BLE001)


    566-566: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    566-566: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    567-567: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    592-592: Unused function argument: ctx

    (ARG001)


    604-604: Consider moving this statement to an else block

    (TRY300)


    605-605: Do not catch blind exception: Exception

    (BLE001)


    606-606: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    606-606: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    607-607: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    612-612: Unused function argument: ctx

    (ARG001)


    614-614: PEP 484 prohibits implicit Optional

    Convert to T | None

    (RUF013)


    679-679: Consider moving this statement to an else block

    (TRY300)


    680-680: Do not catch blind exception: Exception

    (BLE001)


    681-681: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    681-681: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    683-683: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    684-684: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    689-689: Unused function argument: ctx

    (ARG001)


    708-708: Abstract raise to an inner function

    (TRY301)


    708-708: Create your own exception

    (TRY002)


    708-708: Avoid specifying long messages outside the exception class

    (TRY003)


    711-711: Abstract raise to an inner function

    (TRY301)


    711-711: Create your own exception

    (TRY002)


    724-724: Do not catch blind exception: Exception

    (BLE001)


    725-725: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    725-725: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    726-726: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

    (B904)


    726-726: Create your own exception

    (TRY002)


    726-726: Avoid specifying long messages outside the exception class

    (TRY003)


    726-726: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    731-731: Unused function argument: ctx

    (ARG001)


    775-775: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    796-796: Do not catch blind exception: Exception

    (BLE001)


    797-797: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    797-797: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    799-799: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    800-800: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    984-984: Unused function argument: ctx

    (ARG001)


    995-995: Consider moving this statement to an else block

    (TRY300)


    996-996: Do not catch blind exception: Exception

    (BLE001)


    997-997: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    997-997: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    998-998: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1002-1002: Unused function argument: ctx

    (ARG001)


    1003-1003: PEP 484 prohibits implicit Optional

    Convert to T | None

    (RUF013)


    1004-1004: PEP 484 prohibits implicit Optional

    Convert to T | None

    (RUF013)


    1033-1033: Do not catch blind exception: Exception

    (BLE001)


    1034-1034: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    1034-1034: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1035-1035: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1039-1039: Unused function argument: ctx

    (ARG001)


    1040-1040: PEP 484 prohibits implicit Optional

    Convert to T | None

    (RUF013)


    1061-1061: Consider moving this statement to an else block

    (TRY300)


    1062-1062: Do not catch blind exception: Exception

    (BLE001)


    1063-1063: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    1063-1063: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1064-1064: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1068-1068: Unused function argument: ctx

    (ARG001)


    1089-1089: Consider moving this statement to an else block

    (TRY300)


    1090-1090: Do not catch blind exception: Exception

    (BLE001)


    1091-1091: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    1091-1091: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1092-1092: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1097-1097: Unused function argument: ctx

    (ARG001)


    1183-1183: Consider moving this statement to an else block

    (TRY300)


    1185-1185: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1186-1186: Do not catch blind exception: Exception

    (BLE001)


    1187-1187: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    1187-1187: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1188-1188: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1193-1193: Unused function argument: ctx

    (ARG001)


    1197-1197: PEP 484 prohibits implicit Optional

    Convert to T | None

    (RUF013)


    1288-1288: Do not catch blind exception: Exception

    (BLE001)


    1289-1289: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    1289-1289: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1290-1290: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1294-1294: Unused function argument: ctx

    (ARG001)


    1302-1302: Local variable enabled is assigned to but never used

    Remove assignment to unused variable enabled

    (F841)


    1305-1305: Consider moving this statement to an else block

    (TRY300)


    1306-1306: Do not catch blind exception: Exception

    (BLE001)


    1307-1307: Use logging.exception instead of logging.error

    Replace with exception

    (TRY400)


    1307-1307: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    1308-1308: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)

    🔇 Additional comments (14)
    pyproject.toml (2)

    3-3: LGTM: Version bump aligns with feature scope.

    The version bump from 1.1.3 to 1.4.0 appropriately reflects the significant feature additions (geometry nodes, asset integrations, telemetry system).


    18-19: Versions exist; verify supabase transitive dependencies for security.

    Confirmed: supabase 2.0.0 (Oct 2023) and tomli 2.0.0 (Dec 2021) are both available on PyPI. tomli has no known CVEs. However, supabase has transitive dependency vulnerabilities (e.g., CVE-2025-27516 affecting Jinja2 via supabase-pydantic). Before merging, scan supabase's transitive dependencies (httpx, Jinja2, etc.) for known vulnerabilities to ensure the telemetry backend integration doesn't introduce security gaps.

    .gitignore (1)

    11-16: LGTM: Appropriate ignore patterns added.

    Adding .DS_Store (macOS metadata) and src/blender_mcp/config.py (local secrets/config) to the ignore list follows best practices for keeping the repository clean and preventing accidental commits of sensitive information.

    TERMS_AND_CONDITIONS.md (2)

    15-59: LGTM: Clear and transparent data collection disclosure.

    The terms clearly specify what data is collected (prompts, code, scene metadata) and what isn't (screenshots, models, personal files). The disclosure that data may be used for AI training and released as public datasets is appropriately transparent.


    103-122: Users grant a perpetual, irrevocable license.

    Users should be aware that by using Blender MCP, they grant a "worldwide, royalty-free, perpetual license" to their prompts, generated code, and scene metadata. This is reasonable for an open-source project collecting training data, but it's a significant commitment that users should understand before agreeing.

    Consider adding a more prominent consent checkbox or acceptance flow in the UI to ensure users actively acknowledge these terms rather than passively agreeing by using the software.

    README.md (4)

    7-7: LGTM: Important safety warning added.

    The warning about unofficial websites helps protect users from potential phishing or malicious sites impersonating this project.


    87-97: LGTM: Environment variables properly documented.

    The addition of BLENDER_HOST and BLENDER_PORT environment variables with a Docker example helps users configure remote Blender connections.


    118-156: LGTM: Comprehensive Cursor integration instructions.

    The instructions clearly differentiate between Mac and Windows setups, and explain the difference between global and project-specific MCP server configurations.


    162-166: LGTM: VS Code integration added.

    The addition of Visual Studio Code integration with prerequisites and an install badge expands IDE support and improves accessibility.

    src/blender_mcp/telemetry_decorator.py (3)

    16-38: LGTM: Robust telemetry implementation for sync functions.

    The sync_wrapper correctly:

    • Tracks execution time and success status
    • Re-raises exceptions after recording error messages
    • Ensures telemetry recording doesn't disrupt the wrapped function (debug log on failure)

    The broad exception catching (Ruff BLE001) is intentional to ensure telemetry failures never impact normal operation.


    39-58: LGTM: Async wrapper mirrors sync implementation.

    The async_wrapper correctly handles asynchronous functions with the same robust error handling and telemetry recording patterns as the sync version.


    59-65: LGTM: Proper sync/async detection.

    The decorator correctly uses inspect.iscoroutinefunction to automatically select the appropriate wrapper type, supporting both synchronous and asynchronous tool functions.

    src/blender_mcp/server.py (2)

    16-18: LGTM: Telemetry integration imports.

    The telemetry imports are well-structured, importing only the necessary functions from the telemetry module.


    25-27: LGTM: Centralized default configuration.

    Good practice to define default host/port as named constants for maintainability.

    Comment thread src/blender_mcp/server.py
    Comment on lines +304 to +328
    try:
    blender = get_blender_connection()

    # Create temp file path
    temp_dir = tempfile.gettempdir()
    temp_path = os.path.join(temp_dir, f"blender_screenshot_{os.getpid()}.png")

    result = blender.send_command("get_viewport_screenshot", {
    "max_size": max_size,
    "filepath": temp_path,
    "format": "png"
    })

    if "error" in result:
    raise Exception(result["error"])

    if not os.path.exists(temp_path):
    raise Exception("Screenshot file was not created")

    # Read the file
    with open(temp_path, 'rb') as f:
    image_bytes = f.read()

    # Delete the temp file
    os.remove(temp_path)

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    ⚠️ Potential issue | 🟡 Minor

    Temp file not cleaned up on error path.

    If reading the file fails (line 324-325), the temp file remains on disk. Use a try/finally or context manager to ensure cleanup.

    Proposed fix
             if not os.path.exists(temp_path):
                 raise Exception("Screenshot file was not created")
             
    -        # Read the file
    -        with open(temp_path, 'rb') as f:
    -            image_bytes = f.read()
    -        
    -        # Delete the temp file
    -        os.remove(temp_path)
    +        try:
    +            # Read the file
    +            with open(temp_path, 'rb') as f:
    +                image_bytes = f.read()
    +        finally:
    +            # Delete the temp file
    +            if os.path.exists(temp_path):
    +                os.remove(temp_path)
             
             return Image(data=image_bytes, format="png")
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    try:
    blender = get_blender_connection()
    # Create temp file path
    temp_dir = tempfile.gettempdir()
    temp_path = os.path.join(temp_dir, f"blender_screenshot_{os.getpid()}.png")
    result = blender.send_command("get_viewport_screenshot", {
    "max_size": max_size,
    "filepath": temp_path,
    "format": "png"
    })
    if "error" in result:
    raise Exception(result["error"])
    if not os.path.exists(temp_path):
    raise Exception("Screenshot file was not created")
    # Read the file
    with open(temp_path, 'rb') as f:
    image_bytes = f.read()
    # Delete the temp file
    os.remove(temp_path)
    try:
    blender = get_blender_connection()
    # Create temp file path
    temp_dir = tempfile.gettempdir()
    temp_path = os.path.join(temp_dir, f"blender_screenshot_{os.getpid()}.png")
    result = blender.send_command("get_viewport_screenshot", {
    "max_size": max_size,
    "filepath": temp_path,
    "format": "png"
    })
    if "error" in result:
    raise Exception(result["error"])
    if not os.path.exists(temp_path):
    raise Exception("Screenshot file was not created")
    try:
    # Read the file
    with open(temp_path, 'rb') as f:
    image_bytes = f.read()
    finally:
    # Delete the temp file
    if os.path.exists(temp_path):
    os.remove(temp_path)
    return Image(data=image_bytes, format="png")
    🧰 Tools
    🪛 Ruff (0.14.10)

    318-318: Abstract raise to an inner function

    (TRY301)


    318-318: Create your own exception

    (TRY002)


    321-321: Abstract raise to an inner function

    (TRY301)


    321-321: Create your own exception

    (TRY002)


    321-321: Avoid specifying long messages outside the exception class

    (TRY003)

    🤖 Prompt for AI Agents
    In @src/blender_mcp/server.py around lines 304 - 328, The temp file created for
    the Blender screenshot (temp_path) is not guaranteed to be removed if reading
    the file or any later step raises; wrap the screenshot creation/reading/delete
    sequence in a try/finally (or use a context manager surrounding the lifecycle)
    so that after calling get_blender_connection() and
    blender.send_command("get_viewport_screenshot", ...) you always attempt to
    os.remove(temp_path) in the finally block if the file exists; ensure existing
    error checks (result["error"] and existence of temp_path) remain and that
    image_bytes is set from the with open(...) read inside the try before returning
    or re-raising the exception.
    

    Comment thread src/blender_mcp/server.py
    Comment on lines +686 to +691
    @telemetry_tool("download_sketchfab_model")
    @mcp.tool()
    def get_sketchfab_model_preview(
    ctx: Context,
    uid: str
    ) -> Image:

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    ⚠️ Potential issue | 🟡 Minor

    Incorrect telemetry decorator name.

    The @telemetry_tool("download_sketchfab_model") decorator is applied to get_sketchfab_model_preview, which is incorrect. The tool name should match the function.

    Proposed fix
    -@telemetry_tool("download_sketchfab_model")
    +@telemetry_tool("get_sketchfab_model_preview")
     @mcp.tool()
     def get_sketchfab_model_preview(
         ctx: Context,
         uid: str
     ) -> Image:
    🧰 Tools
    🪛 Ruff (0.14.10)

    689-689: Unused function argument: ctx

    (ARG001)

    🤖 Prompt for AI Agents
    In @src/blender_mcp/server.py around lines 686 - 691, The telemetry decorator on
    get_sketchfab_model_preview is using the wrong tool name; update the
    @telemetry_tool(...) argument to match the function name (e.g., change
    "download_sketchfab_model" to "get_sketchfab_model_preview") so the telemetry
    identifier aligns with the get_sketchfab_model_preview tool and decorator.
    

    Comment thread src/blender_mcp/server.py
    Comment on lines +729 to +734
    @mcp.tool()
    def download_sketchfab_model(
    ctx: Context,
    uid: str,
    target_size: float
    ) -> str:

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    ⚠️ Potential issue | 🟡 Minor

    Missing telemetry decorator on download_sketchfab_model.

    This tool lacks the @telemetry_tool decorator, unlike other similar tools, causing inconsistent telemetry tracking.

    Proposed fix
    +@telemetry_tool("download_sketchfab_model")
     @mcp.tool()
     def download_sketchfab_model(
         ctx: Context,
         uid: str,
         target_size: float
     ) -> str:
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    @mcp.tool()
    def download_sketchfab_model(
    ctx: Context,
    uid: str,
    target_size: float
    ) -> str:
    @telemetry_tool("download_sketchfab_model")
    @mcp.tool()
    def download_sketchfab_model(
    ctx: Context,
    uid: str,
    target_size: float
    ) -> str:
    🧰 Tools
    🪛 Ruff (0.14.10)

    731-731: Unused function argument: ctx

    (ARG001)

    🤖 Prompt for AI Agents
    In @src/blender_mcp/server.py around lines 729 - 734, The
    download_sketchfab_model tool is missing the @telemetry_tool decorator, causing
    inconsistent telemetry; add the @telemetry_tool decorator directly above the
    existing @mcp.tool() on the download_sketchfab_model function and ensure
    telemetry_tool is imported/available in the module (matching how other tools are
    decorated) so telemetry events are recorded consistently for
    download_sketchfab_model.
    

    @coderabbitai coderabbitai Bot left a comment

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    Actionable comments posted: 3

    🤖 Fix all issues with AI agents
    In @addon.py:
    - Around line 2849-2859: In _get_socket_default_value, replace the bare
    "except:" with a specific exception handler (use "except Exception:") so only
    regular exceptions from accessing or converting socket.default_value are caught;
    update the except clause in the try/except block that handles
    hasattr(socket.default_value, '__len__') and list(...) conversion to use "except
    Exception:" and keep returning None on error.
    - Around line 2894-2927: Replace the bare except with "except Exception as e:"
    in _verify_node_identifier and log the exception (using
    self.logger.exception(...) if a logger attribute exists, or
    logging.getLogger(__name__).exception(...)) including context like
    node_type_name and the exception/traceback (traceback.format_exc()) so the
    caught exception variable e is used for debugging; keep the finally block that
    removes temp_tree to ensure cleanup.
    
    🧹 Nitpick comments (4)
    addon.py (4)

    1218-1246: Consider returning consistent structure and improving error handling.

    The method returns different dictionary structures for success vs. error cases. For consistency with other status methods in the codebase, consider standardizing the response format.

    Additionally, the broad exception catch on line 1239 could mask unexpected errors. Consider catching more specific exceptions or at least logging the full traceback.

    ♻️ Proposed improvements
     def get_geometry_nodes_status(self):
         """Get the status of the geometry nodes feature in Blender.
     
         Returns:
             dict: A dictionary containing information about the geometry nodes feature status.
         """
         try:
             # Get the geometry nodes setting from Blender preferences
             enabled = bpy.context.scene.blendermcp_use_geometry_nodes
             if enabled:
                 return {
    -                "status": "success",
                     "enabled": True,
                     "message": "Geometry nodes feature is enabled."
                 }
             else:
                 return {
    -                "status": "warning",
                     "enabled": False,
                     "message": "Geometry nodes feature is disabled. Enable it in the BlenderMCP panel to use geometry node commands."
                 }
    -    except Exception as e:
    +    except Exception as e:
    +        import traceback
    +        traceback.print_exc()
             # Handle errors when checking status
             return {
    -            "status": "error",
                 "enabled": False,
                 "message": f"Error checking geometry nodes status: {str(e)}"
             }

    2433-2459: Well-structured delegation pattern with room for improved error handling.

    The method appropriately delegates to helper functions, making it maintainable. However, consider:

    1. The broad Exception catch on line 2458 could hide unexpected errors
    2. Add more context to error messages for debugging
    💡 Optional enhancement
             if input_sockets:
                 self._set_modifier_parameters(geometry_modifier, node_group, input_sockets)
     
             return {
                 "status": "success",
                 "object_name": object_name,
                 "modifier_name": geometry_modifier.name,
                 "node_group_name": node_group.name,
                 "nodes": created_nodes,
                 "links": created_links
             }
         except Exception as e:
    +        import traceback
    +        traceback.print_exc()
             return {"error": f"Error completing geometry node network: {str(e)}"}

    2615-2635: Silent error handling may hide issues with parameter setting.

    The method silently catches and prints exceptions when setting modifier parameters (lines 2632, 2634). This could make debugging difficult if parameters fail to set correctly. Consider at least collecting these errors and returning them in the response, or logging them more prominently.

    💡 Enhanced error reporting
     def _set_modifier_parameters(self, geometry_modifier, node_group, input_sockets):
         """Set modifier parameters from input socket values"""
    +    errors = []
         try:
             group_input_node = next((node for node in node_group.nodes if node.type == 'GROUP_INPUT'), None)
             if not group_input_node:
                 group_input_node = node_group.nodes.new('NodeGroupInput')
     
             socket_dict = {output.name: i for i, output in enumerate(group_input_node.outputs)}
     
             for socket in input_sockets:
                 if "value" in socket and socket.get("type") != "NodeSocketGeometry":
                     socket_name = socket.get("name")
                     if socket_name in socket_dict:
                         socket_key = f"Socket_{socket_dict[socket_name]}"
                         try:
                             if socket_key in geometry_modifier:
                                 geometry_modifier[socket_key] = socket["value"]
                         except Exception as e:
    -                        print(f"Error setting modifier parameter {socket_key}: {e}")
    +                        error_msg = f"Error setting modifier parameter {socket_key}: {e}"
    +                        print(error_msg)
    +                        errors.append(error_msg)
         except Exception as e:
    -        print(f"Error setting modifier parameters: {e}")
    +        error_msg = f"Error setting modifier parameters: {e}"
    +        print(error_msg)
    +        errors.append(error_msg)
    +    return errors if errors else None

    Then in complete_geometry_node, you could include parameter errors in the response:

    param_errors = self._set_modifier_parameters(geometry_modifier, node_group, input_sockets)
    result = {
        "status": "success",
        ...
    }
    if param_errors:
        result["parameter_warnings"] = param_errors
    return result

    2831-2846: Variable shadowing: rename loop variable to avoid confusion.

    Line 2836 uses socket as a loop variable, which shadows the socket module imported at line 8. While this works because the import is in a different scope, it could cause confusion. Consider using a more specific name like socket_item or sock.

     def _collect_socket_info(self, node, is_input: bool = True) -> List[SocketInfo]:
         """Collect node socket information"""
         socket_list = node.inputs if is_input else node.outputs
         sockets = []
     
    -    for socket in socket_list:
    +    for sock in socket_list:
    -        default_value = self._get_socket_default_value(socket)
    +        default_value = self._get_socket_default_value(sock)
             sockets.append(SocketInfo(
    -            name=socket.name,
    +            name=sock.name,
    -            type=socket.type,
    +            type=sock.type,
    -            description=socket.description,
    +            description=sock.description,
    -            enabled=socket.enabled,
    +            enabled=sock.enabled,
    -            hide=socket.hide,
    +            hide=sock.hide,
                 default_value=default_value
             ))
     
         return sockets
    📜 Review details

    Configuration used: defaults

    Review profile: CHILL

    Plan: Pro

    📥 Commits

    Reviewing files that changed from the base of the PR and between a1e19b9 and e0d4c9b.

    📒 Files selected for processing (1)
    • addon.py
    🧰 Additional context used
    🧬 Code graph analysis (1)
    addon.py (1)
    src/blender_mcp/server.py (3)
    • get_geometry_nodes_status (1294-1308)
    • get_node_info (1096-1188)
    • complete_geometry_node (1192-1290)
    🪛 Ruff (0.14.10)
    addon.py

    1239-1239: Do not catch blind exception: Exception

    (BLE001)


    1244-1244: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2450-2457: Consider moving this statement to an else block

    (TRY300)


    2458-2458: Do not catch blind exception: Exception

    (BLE001)


    2459-2459: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2531-2531: Do not catch blind exception: Exception

    (BLE001)


    2532-2532: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

    (B904)


    2532-2532: Create your own exception

    (TRY002)


    2532-2532: Avoid specifying long messages outside the exception class

    (TRY003)


    2532-2532: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2542-2542: Do not catch blind exception: Exception

    (BLE001)


    2557-2557: Do not catch blind exception: Exception

    (BLE001)


    2590-2590: Abstract raise to an inner function

    (TRY301)


    2590-2590: Create your own exception

    (TRY002)


    2590-2590: Avoid specifying long messages outside the exception class

    (TRY003)


    2592-2592: Abstract raise to an inner function

    (TRY301)


    2592-2592: Create your own exception

    (TRY002)


    2592-2592: Avoid specifying long messages outside the exception class

    (TRY003)


    2598-2598: Abstract raise to an inner function

    (TRY301)


    2598-2598: Create your own exception

    (TRY002)


    2598-2598: Avoid specifying long messages outside the exception class

    (TRY003)


    2600-2600: Abstract raise to an inner function

    (TRY301)


    2600-2600: Create your own exception

    (TRY002)


    2600-2600: Avoid specifying long messages outside the exception class

    (TRY003)


    2610-2610: Do not catch blind exception: Exception

    (BLE001)


    2611-2611: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

    (B904)


    2611-2611: Create your own exception

    (TRY002)


    2611-2611: Avoid specifying long messages outside the exception class

    (TRY003)


    2611-2611: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2632-2632: Do not catch blind exception: Exception

    (BLE001)


    2634-2634: Do not catch blind exception: Exception

    (BLE001)


    2690-2695: Consider moving this statement to an else block

    (TRY300)


    2696-2696: Do not catch blind exception: Exception

    (BLE001)


    2697-2697: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2769-2769: Abstract raise to an inner function

    (TRY301)


    2769-2769: Avoid specifying long messages outside the exception class

    (TRY003)


    2783-2783: Do not catch blind exception: Exception

    (BLE001)


    2784-2784: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2787-2787: Use explicit conversion flag

    Replace with conversion flag

    (RUF010)


    2804-2804: Avoid specifying long messages outside the exception class

    (TRY003)


    2807-2807: Prefer TypeError exception for invalid type

    (TRY004)


    2807-2807: Avoid specifying long messages outside the exception class

    (TRY003)


    2836-2836: Import socket from line 8 shadowed by loop variable

    (F402)


    2858-2858: Do not use bare except

    (E722)


    2922-2922: Consider moving this statement to an else block

    (TRY300)


    2923-2923: Do not catch blind exception: Exception

    (BLE001)


    2923-2923: Local variable e is assigned to but never used

    Remove assignment to unused variable e

    (F841)


    2981-2981: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    3014-3014: Consider moving this statement to an else block

    (TRY300)


    3015-3015: Do not catch blind exception: Exception

    (BLE001)


    3019-3019: f-string without any placeholders

    Remove extraneous f prefix

    (F541)


    3140-3140: Do not catch blind exception: Exception

    (BLE001)

    🔇 Additional comments (2)
    addon.py (2)

    2510-2534: LGTM: Clear node creation logic with proper error context.

    The method properly wraps each node creation in a try-except block, providing specific error messages that include the node type. This is good defensive programming that will help with debugging.


    3411-3415: LGTM: UI integration follows established patterns.

    The geometry nodes toggle is properly integrated into the scene properties and follows the same pattern as other feature flags (Polyhaven, Sketchfab, etc.). The property is correctly registered in register() and cleaned up in unregister().

    Comment thread addon.py
    Comment on lines +2822 to +2829
    def _register_node_info_cache(self):
    """Register custom properties"""
    if not hasattr(bpy.types.WindowManager, "node_info_cache"):
    bpy.types.WindowManager.node_info_cache = bpy.props.StringProperty(
    name="Node Info Cache",
    description="Cache for node information",
    default=""
    )

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    🛠️ Refactor suggestion | 🟠 Major

    Cache mechanism lacks invalidation strategy.

    The cache is stored in WindowManager.node_info_cache and persists across sessions, but there's no mechanism to invalidate it when:

    • Blender version changes
    • Addons are loaded/unloaded (which may add new node types)
    • The geometry node system is updated

    Consider adding version checking or a cache key that includes relevant version information.

    🔧 Add cache versioning
     def _register_node_info_cache(self):
         """Register custom properties"""
         if not hasattr(bpy.types.WindowManager, "node_info_cache"):
             bpy.types.WindowManager.node_info_cache = bpy.props.StringProperty(
                 name="Node Info Cache",
                 description="Cache for node information",
                 default=""
             )
    +        bpy.types.WindowManager.node_info_cache_version = bpy.props.StringProperty(
    +            name="Node Info Cache Version",
    +            description="Blender version for cached node info",
    +            default=""
    +        )

    Then in _get_nodes_from_cache_or_collect:

     def _get_nodes_from_cache_or_collect(self) -> List[NodeInfo]:
         """Get node information from cache or collect new ones"""
         wm = bpy.context.window_manager
    +    current_version = f"{bpy.app.version[0]}.{bpy.app.version[1]}.{bpy.app.version[2]}"
     
         # Try to get data from cache
    -    if wm.node_info_cache:
    +    if wm.node_info_cache and wm.node_info_cache_version == current_version:
             print(f"Using cache..")
             ...
         
         # After collecting:
         self._update_cache(node_infos)
    +    wm.node_info_cache_version = current_version

    Comment thread addon.py
    Comment on lines +2849 to +2859
    def _get_socket_default_value(self, socket):
    """Get socket default value safely"""
    if not hasattr(socket, 'default_value'):
    return None
    try:
    if hasattr(socket.default_value, '__len__'):
    return list(socket.default_value)
    else:
    return socket.default_value
    except:
    return None

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    ⚠️ Potential issue | 🟡 Minor

    Replace bare except with specific exception type.

    Line 2858 uses a bare except: clause which catches all exceptions including SystemExit and KeyboardInterrupt. This is generally considered bad practice. Use except Exception: instead to catch only regular exceptions.

     def _get_socket_default_value(self, socket):
         """Get socket default value safely"""
         if not hasattr(socket, 'default_value'):
             return None
         try:
             if hasattr(socket.default_value, '__len__'):
                 return list(socket.default_value)
             else:
                 return socket.default_value
    -    except:
    +    except Exception:
             return None
    📝 Committable suggestion

    ‼️ IMPORTANT
    Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    Suggested change
    def _get_socket_default_value(self, socket):
    """Get socket default value safely"""
    if not hasattr(socket, 'default_value'):
    return None
    try:
    if hasattr(socket.default_value, '__len__'):
    return list(socket.default_value)
    else:
    return socket.default_value
    except:
    return None
    def _get_socket_default_value(self, socket):
    """Get socket default value safely"""
    if not hasattr(socket, 'default_value'):
    return None
    try:
    if hasattr(socket.default_value, '__len__'):
    return list(socket.default_value)
    else:
    return socket.default_value
    except Exception:
    return None
    🧰 Tools
    🪛 Ruff (0.14.10)

    2858-2858: Do not use bare except

    (E722)

    🤖 Prompt for AI Agents
    In @addon.py around lines 2849 - 2859, In _get_socket_default_value, replace the
    bare "except:" with a specific exception handler (use "except Exception:") so
    only regular exceptions from accessing or converting socket.default_value are
    caught; update the except clause in the try/except block that handles
    hasattr(socket.default_value, '__len__') and list(...) conversion to use "except
    Exception:" and keep returning None on error.
    

    Comment thread addon.py
    Comment on lines +2894 to +2927
    def _verify_node_identifier(self, node_type_name: str) -> Tuple[
    bool, List[SocketInfo], List[SocketInfo], List[PropertyInfo]]:
    """Verify node identifier is valid for creating a node and get node information

    Args:
    node_type_name: Node type name

    Returns:
    Tuple: (is_usable, input_sockets, output_sockets, property_info)
    """
    # Create a temporary geometry node tree for testing
    temp_tree = bpy.data.node_groups.new('TempNodeTree', 'GeometryNodeTree')
    inputs = []
    outputs = []
    properties = []

    try:
    # Try to create a node
    node = temp_tree.nodes.new(node_type_name)
    node_type = getattr(bpy.types, node_type_name)

    # Get socket information
    inputs = self._collect_socket_info(node, is_input=True)
    outputs = self._collect_socket_info(node, is_input=False)

    # Get property information
    properties = self._collect_property_info(node_type)

    return True, inputs, outputs, properties
    except Exception as e:
    return False, inputs, outputs, properties
    finally:
    # Clean up
    bpy.data.node_groups.remove(temp_tree)

    Copy link
    Copy Markdown

    Choose a reason for hiding this comment

    The reason will be displayed to describe this comment to others. Learn more.

    ⚠️ Potential issue | 🟡 Minor

    Replace bare except with specific exception type and improve error handling.

    Lines 2923-2924 use a bare except: clause and assign to an unused variable. Replace with except Exception as e: and consider logging the error for debugging purposes.

             # Get property information
             properties = self._collect_property_info(node_type)
     
             return True, inputs, outputs, properties
    -    except Exception as e:
    +    except Exception:
    +        # Optionally log for debugging
    +        # print(f"Cannot create node {node_type_name}: {e}")
             return False, inputs, outputs, properties
         finally:
             # Clean up
             bpy.data.node_groups.remove(temp_tree)
    🧰 Tools
    🪛 Ruff (0.14.10)

    2922-2922: Consider moving this statement to an else block

    (TRY300)


    2923-2923: Do not catch blind exception: Exception

    (BLE001)


    2923-2923: Local variable e is assigned to but never used

    Remove assignment to unused variable e

    (F841)

    🤖 Prompt for AI Agents
    In @addon.py around lines 2894 - 2927, Replace the bare except with "except
    Exception as e:" in _verify_node_identifier and log the exception (using
    self.logger.exception(...) if a logger attribute exists, or
    logging.getLogger(__name__).exception(...)) including context like
    node_type_name and the exception/traceback (traceback.format_exc()) so the
    caught exception variable e is used for debugging; keep the finally block that
    removes temp_tree to ensure cleanup.
    

    @duqich

    duqich commented Jan 11, 2026

    Copy link
    Copy Markdown
    Author

    @ahujasid
    Done. I’ve simplified the code and resolved the conflicts.

    Here is a test example

    pic1 pic0

    @ahujasid

    Copy link
    Copy Markdown
    Owner

    @duqich I think using a tool makes it quite prescriptive, what do you feel about repackaging it as a skill and simplifying it?

    https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview

    @CreateTheImaginable

    Copy link
    Copy Markdown

    @duqich I think using a tool makes it quite prescriptive, what do you feel about repackaging it as a skill and simplifying it?

    https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview

    @ahujasid If we make it use Claude Skills won't that only allow us to use Anthropic's Claude? I have not tried it but I think some people may be experimenting and using other LLMs? Maybe make using Claude Skills optional? 🤔

    @garyritchie

    Copy link
    Copy Markdown

    Most agent harnesses now support skills. A skill that works with Claude Code should also work with OpenCode, Hermes. The frustrating part is the location(s) of installed skills. What a mess.

    Also, are you aware of https://www.blender.org/lab/mcp-server/ ? (I just learned about it today so I'm not sure how it compares.)

    Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

    Projects

    None yet

    Development

    Successfully merging this pull request may close these issues.

    5 participants