├── .python-version ├── .DS_Store ├── rhino_mcp ├── .env.sample ├── grasshopper_mcp_client.gh ├── rhino_mcp │ ├── __init__.py │ ├── replicate_tools.py │ ├── utility_tools.py │ ├── server.py │ ├── rhino_tools.py │ └── grasshopper_tools.py ├── main.py ├── requirements.txt ├── .gitignore ├── pyproject.toml ├── LICENSE └── rhino_mcp_client.py ├── README.md └── GHCodeMCP_old_working.py /.python-version: -------------------------------------------------------------------------------- 1 | 3.10.16 2 | -------------------------------------------------------------------------------- /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SerjoschDuering/rhino-mcp/HEAD/.DS_Store -------------------------------------------------------------------------------- /rhino_mcp/.env.sample: -------------------------------------------------------------------------------- 1 | # copy this file to .env and fill in the values 2 | REPLICATE_TOKEN = -------------------------------------------------------------------------------- /rhino_mcp/grasshopper_mcp_client.gh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SerjoschDuering/rhino-mcp/HEAD/rhino_mcp/grasshopper_mcp_client.gh -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rhino Model Context Protocol (MCP) Integration 3 | ---------------------------------------------- 4 | A package that allows Claude to interact with Rhino through the Model Context Protocol 5 | """ 6 | 7 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /rhino_mcp/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | RhinoMCP - Main entry point 4 | 5 | This script serves as a convenience wrapper to start the RhinoMCP server. 6 | """ 7 | 8 | from rhino_mcp.server import main 9 | 10 | if __name__ == "__main__": 11 | main() -------------------------------------------------------------------------------- /rhino_mcp/requirements.txt: -------------------------------------------------------------------------------- 1 | # Core MCP dependencies 2 | mcp-python>=0.1.0 3 | fastmcp>=0.1.0 4 | 5 | # Server dependencies 6 | uvicorn>=0.15.0 7 | fastapi>=0.68.0 8 | 9 | # Utility dependencies 10 | python-json-logger>=2.0.0 11 | pydantic>=1.8.0 12 | typing-extensions>=4.0.0 13 | requests 14 | pillow -------------------------------------------------------------------------------- /rhino_mcp/.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | # Virtual Environment 24 | venv/ 25 | env/ 26 | ENV/ 27 | 28 | # IDE 29 | .idea/ 30 | .vscode/ 31 | *.swp 32 | *.swo 33 | 34 | # OS 35 | .DS_Store 36 | Thumbs.db 37 | 38 | # Environment files 39 | .env -------------------------------------------------------------------------------- /rhino_mcp/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "rhino-mcp" 3 | version = "0.1.0" 4 | description = "Rhino integration through the Model Context Protocol" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | authors = [ 8 | {name = "Rhino MCP Team"} 9 | ] 10 | license = {text = "MIT"} 11 | classifiers = [ 12 | "Programming Language :: Python :: 3", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | ] 16 | dependencies = [ 17 | "mcp[cli]>=1.3.0", 18 | ] 19 | 20 | [project.scripts] 21 | rhino-mcp = "rhino_mcp.server:main" 22 | 23 | [build-system] 24 | requires = ["setuptools>=61.0", "wheel"] 25 | build-backend = "setuptools.build_meta" 26 | 27 | [tool.setuptools] 28 | package-dir = {"" = "."} -------------------------------------------------------------------------------- /rhino_mcp/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 RhinoMCP Team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp/replicate_tools.py: -------------------------------------------------------------------------------- 1 | """Tools for interacting with Replicate's Flux Depth model.""" 2 | import os 3 | import requests 4 | import base64 5 | import io 6 | import time 7 | import logging 8 | from mcp.server.fastmcp import Context, Image 9 | from PIL import Image as PILImage 10 | 11 | logger = logging.getLogger("ReplicateTools") 12 | 13 | class ReplicateTools: 14 | def __init__(self, app): 15 | self.app = app 16 | self.api_token = os.environ.get('REPLICATE_API_TOKEN') or os.environ.get('REPLICATE_TOKEN') 17 | if not self.api_token: 18 | logger.warning("No Replicate API token found in environment") 19 | self.app.tool()(self.render_rhino_scene) 20 | 21 | def render_rhino_scene(self, ctx: Context, prompt: str) -> Image: 22 | """Transform Rhino viewport with AI using the given prompt, ensure to display the result image in chat afterwads" 23 | 24 | Args: 25 | prompt: A prompt to guide the rendering of the scene 26 | 27 | Returns: 28 | An MCP Image object containing the rendered scene 29 | """ 30 | try: 31 | # Get Rhino viewport image 32 | from .rhino_tools import get_rhino_connection 33 | connection = get_rhino_connection() 34 | result = connection.send_command("capture_viewport", { 35 | "layer": None, 36 | "show_annotations": False, 37 | "max_size": 800 38 | }) 39 | 40 | if result.get("type") != "image": 41 | return "Error: Failed to capture viewport" 42 | 43 | # Get base64 data and prepare request 44 | base64_data = result["source"]["data"] 45 | headers = { 46 | "Authorization": f"Token {self.api_token}", 47 | "Content-Type": "application/json", 48 | } 49 | 50 | # Start prediction 51 | response = requests.post( 52 | "https://api.replicate.com/v1/predictions", 53 | json={ 54 | "version": "black-forest-labs/flux-depth-dev", 55 | "input": { 56 | "prompt": prompt, 57 | "control_image": f"data:image/jpeg;base64,{base64_data}" 58 | } 59 | }, 60 | headers=headers 61 | ) 62 | prediction = response.json() 63 | prediction_url = prediction["urls"]["get"] 64 | 65 | # Poll until complete (max 60 seconds) 66 | for _ in range(30): 67 | time.sleep(2) 68 | response = requests.get(prediction_url, headers=headers) 69 | prediction = response.json() 70 | 71 | # Check if complete 72 | if prediction["status"] == "succeeded" and prediction.get("output"): 73 | # Get image URL and download it 74 | image_url = prediction["output"] 75 | if isinstance(image_url, list): 76 | image_url = image_url[0] 77 | 78 | # Download and convert to MCP Image 79 | image_data = requests.get(image_url).content 80 | img = PILImage.open(io.BytesIO(image_data)) 81 | 82 | # Resize to max 800px while maintaining aspect ratio 83 | max_size = 800 84 | if img.width > max_size or img.height > max_size: 85 | ratio = max_size / max(img.width, img.height) 86 | new_size = (int(img.width * ratio), int(img.height * ratio)) 87 | img = img.resize(new_size, PILImage.Resampling.LANCZOS) 88 | 89 | # Save with controlled quality 90 | buffer = io.BytesIO() 91 | img.save(buffer, format="JPEG", quality=70, optimize=True) 92 | return Image(data=buffer.getvalue(), format="jpeg") 93 | 94 | # Check for errors 95 | if prediction["status"] not in ["processing", "starting"]: 96 | return f"Error: Model failed with status {prediction['status']}" 97 | 98 | return "Error: Prediction timed out" 99 | 100 | except Exception as e: 101 | return f"Error: {str(e)}" -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp/utility_tools.py: -------------------------------------------------------------------------------- 1 | """Tools for web search and email functionality via n8n workflows.""" 2 | from mcp.server.fastmcp import FastMCP, Context, Image 3 | import logging 4 | from typing import Dict, Any, Optional, Union, List 5 | import json 6 | import requests 7 | import uuid 8 | from datetime import datetime 9 | import base64 10 | from io import BytesIO 11 | import time 12 | from PIL import Image as PILImage 13 | import traceback 14 | 15 | # Configure logging 16 | logger = logging.getLogger("UtilityTools") 17 | 18 | class UtilityTools: 19 | """Collection of utility tools that interface with n8n workflows.""" 20 | 21 | def __init__(self, app): 22 | self.app = app 23 | self._register_tools() 24 | 25 | # Configuration for n8n webhooks 26 | self.web_search_webhook_url = "https://run8n.xyz/webhook/webSearchAgent" 27 | self.email_webhook_url = "https://run8n.xyz/webhook/gmailAgent" 28 | self.auth_token = "abc123secretvalue" # This should be in environment variables in production 29 | 30 | def _register_tools(self): 31 | """Register all utility tools with the MCP server.""" 32 | self.app.tool()(self.web_search) 33 | self.app.tool()(self.email_tool) 34 | 35 | def _generate_session_id(self): 36 | """Generate a unique session ID.""" 37 | return datetime.now().strftime("%Y%m%d_%H%M%S_") + str(int(time.time() * 1000))[-3:] 38 | 39 | def _download_image(self, url, max_size=800, jpeg_quality=80): 40 | """Download and process an image from URL.""" 41 | try: 42 | response = requests.get(url) 43 | response.raise_for_status() 44 | 45 | # Open image from bytes 46 | img = PILImage.open(BytesIO(response.content)) 47 | 48 | # Convert to RGB if necessary 49 | if img.mode in ('RGBA', 'P'): 50 | img = img.convert('RGB') 51 | 52 | # Calculate new dimensions while maintaining aspect ratio 53 | ratio = min(max_size / img.width, max_size / img.height) 54 | new_size = (int(img.width * ratio), int(img.height * ratio)) 55 | 56 | # Resize if needed 57 | if ratio < 1: 58 | img = img.resize(new_size, PILImage.Resampling.LANCZOS) 59 | 60 | # Save as JPEG to BytesIO 61 | buffer = BytesIO() 62 | img.save(buffer, format="JPEG", quality=jpeg_quality) 63 | 64 | # Convert to base64 65 | image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8') 66 | 67 | return { 68 | "data": image_base64, 69 | "format": "jpeg", 70 | "width": img.width, 71 | "height": img.height 72 | } 73 | 74 | except Exception as e: 75 | logger.error(f"Error downloading/processing image from {url}: {str(e)}") 76 | return None 77 | 78 | def _parse_search_response(self, response_data: Dict[str, Any], download_images: bool = False) -> Dict[str, Any]: 79 | """Parse the search response from n8n webhook.""" 80 | try: 81 | # Extract data from response 82 | output = json.loads(response_data.get("output", "{}")) 83 | 84 | # Create result dictionary 85 | result = { 86 | "summary": output.get("shortSummary", "No summary available"), 87 | "report": output.get("searchResultReport", "No detailed report available"), 88 | "sources": output.get("sources", []), 89 | "image_urls": output.get("imageUrl", []), 90 | "images": [] # Will be populated if download_images is True 91 | } 92 | 93 | return result 94 | 95 | except Exception as e: 96 | logger.error(f"Error parsing search response: {str(e)}") 97 | return {"status": "error", "message": f"Failed to parse response: {str(e)}"} 98 | 99 | def web_search(self, ctx: Context, user_intent: str, download_images: bool = False) -> str: 100 | """Perform a web search via n8n webhook. 101 | 102 | Args: 103 | user_intent: The search query or intent 104 | download_images: Whether to download and process images 105 | 106 | Returns: 107 | Search results in markdown format 108 | """ 109 | try: 110 | session_id = self._generate_session_id() 111 | 112 | headers = { 113 | "Authorization": self.auth_token, 114 | "Content-Type": "application/json" 115 | } 116 | 117 | payload = { 118 | "userIntent": user_intent, 119 | "downloadImages": download_images, 120 | "sessionId": session_id 121 | } 122 | 123 | response = requests.get( 124 | self.web_search_webhook_url, 125 | headers=headers, 126 | json=payload 127 | ) 128 | 129 | response.raise_for_status() 130 | result = response.json() 131 | 132 | # If images need to be downloaded 133 | if download_images and "imageUrls" in result: 134 | images = [] 135 | for url in result["imageUrls"]: 136 | image_data = self._download_image(url) 137 | if image_data: 138 | images.append(image_data) 139 | result["images"] = images 140 | 141 | return json.dumps(result, indent=2) 142 | 143 | except Exception as e: 144 | error_msg = f"Error performing web search: {str(e)}" 145 | logger.error(error_msg) 146 | return error_msg 147 | 148 | def email_tool(self, ctx: Context, user_intent: str) -> str: 149 | """Search and interact with emails via n8n webhook. 150 | 151 | Args: 152 | user_intent: The email search query or intent 153 | 154 | Returns: 155 | Email results in markdown format 156 | """ 157 | try: 158 | session_id = self._generate_session_id() 159 | 160 | headers = { 161 | "Authorization": self.auth_token, 162 | "Content-Type": "application/json" 163 | } 164 | 165 | payload = { 166 | "userIntent": user_intent, 167 | "sessionId": session_id 168 | } 169 | 170 | response = requests.get( 171 | self.email_webhook_url, 172 | headers=headers, 173 | json=payload 174 | ) 175 | 176 | response.raise_for_status() 177 | 178 | # Return the markdown response directly 179 | return response.text 180 | 181 | except Exception as e: 182 | error_msg = f"Error searching emails: {str(e)}" 183 | logger.error(error_msg) 184 | return error_msg -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RhinoMCP - Rhino Model Context Protocol Integration 2 | 3 | RhinoMCP connects Rhino, Grasshopper and more to Claude AI through the Model Context Protocol (MCP), allowing Claude to directly interact with and control Rhino + Grasshopper. If you provide a replicate.com api key you can also AI render images. This integration enables prompt-assisted 3D modeling, scene creation, and manipulation. (inspired by [blender_mcp](https://github.com/ahujasid/blender-mcp)) 4 | 5 | ## Features 6 | 7 | #### Rhino 8 | - **Two-way communication**: Connect Claude AI to Rhino through a socket-based server 9 | - **Object manipulation and management**: Create and modify 3D objects in Rhino including metadata 10 | - **Layer management**: View and interact with Rhino layers 11 | - **Scene inspection**: Get detailed information about the current Rhino scene (incl. screencapture) 12 | - **Code execution**: Run arbitrary Python code in Rhino from Claude 13 | 14 | #### Grasshopper 15 | - **Code execution**: Run arbitrary Python code in Grasshopper from Claude - includes the generation of gh components 16 | - **Gh canvas inspection**: Get detailed information about your Grasshopper definition, including component graph and parameters 17 | - **Component management**: Update script components, modify parameters, and manage code references 18 | - **External code integration**: Link script components to external Python files for better code organization 19 | - **Real-time feedback**: Get component states, error messages, and runtime information 20 | - **Non-blocking communication**: Stable two-way communication via HTTP server 21 | 22 | ##### Replicate 23 | - **AI Models**: replicate offers thousands of AI models via API, implemented here: a stable diffusion variant 24 | 25 | ## Components 26 | 27 | The system consists of two main components: 28 | 29 | 1. **Rhino-side Script (`rhino_script.py`)**: A Python script that runs inside Rhino to create a socket server that receives and executes commands 30 | 2. **MCP Server (`rhino_mcp/server.py`)**: A Python server that implements the Model Context Protocol and connects to the Rhino script 31 | 32 | ## Installation 33 | 34 | ### Prerequisites 35 | 36 | - Rhino 7 or newer 37 | - Python 3.10 or newer 38 | - Conda (for environment management) 39 | - A Replicate API token (for AI-powered features) 40 | 41 | ### Setting up the Python Environment 42 | 43 | 1. Create a new conda environment with Python 3.10: 44 | ```bash 45 | conda create -n rhino_mcp python=3.10 46 | conda activate rhino_mcp 47 | ``` 48 | 49 | 2. Install the `uv` package manager: 50 | ```bash 51 | pip install uv 52 | ``` 53 | 54 | 3. Install the package in development mode: 55 | ```bash 56 | cd rhino_mcp # Navigate to the package directory 57 | uv pip install -e . 58 | ``` 59 | 60 | ### Installing the Rhino-side Script 61 | 62 | 1. Open Rhino 7 63 | 2. Open the Python Editor: 64 | - Click on the "Tools" menu 65 | - Select "Python Script" -> "Run.." 66 | - Navigate to and select `rhino_mcp_client.py` 67 | 4. The script will start automatically and you should see these messages in the Python Editor: 68 | ``` 69 | RhinoMCP script loaded. Server started automatically. 70 | To stop the server, run: stop_server() 71 | ``` 72 | 73 | ### Running the MCP Server 74 | 75 | The MCP server will be started automatically by Claude Desktop using the configuration in `claude_desktop_config.json`. You don't need to start it manually. 76 | 77 | ### Starting the Connection 78 | 79 | 1. First, start the Rhino script: 80 | - Open Rhino 7 81 | - Open the Python Editor 82 | - Open and run `rhino_mcp_client.py` 83 | - Verify you see the startup messages in the Python Editor 84 | 85 | 2. Then start Claude Desktop: 86 | - It will automatically start the MCP server when needed 87 | - The connection between Claude and Rhino will be established automatically 88 | 89 | ### Managing the Connection 90 | 91 | - To stop the Rhino script server: 92 | - In the Python Editor, type `stop_server()` and press Enter 93 | - You should see "RhinoMCP server stopped" in the outputt 94 | 95 | ### Claude Integration 96 | 97 | To integrate with Claude Desktop: 98 | 99 | 1. Go to Claude Desktop > Settings > Developer > Edit Config 100 | 2. Open the `claude_desktop_config.json` file and add the following configuration: 101 | 102 | ```json 103 | { 104 | "mcpServers": { 105 | "rhino": { 106 | "command": "/Users/Joo/miniconda3/envs/rhino_mcp/bin/python", 107 | "args": [ 108 | "-m", "rhino_mcp.server" 109 | ] 110 | } 111 | } 112 | } 113 | ``` 114 | 115 | Make sure to: 116 | - Replace the Python path with the path to Python in your conda environment 117 | - Save the file and restart Claude Desktop 118 | 119 | ### Cursor IDE Integration 120 | 121 | Using cursor has the potential benifit that you can use it to organise your colelction of python scripts you use for ghpython components (especialyl when you use grasshoppers reference code functionality). Morover, you can utilize its codebase indexing features, add Rhino/Grasshopper SDK references and so on. 122 | 123 | To integrate with Cursor IDE: 124 | 125 | 1. Locate or create the file `~/.cursor/mcp.json` (in your home directory) 126 | 2. Add the same configuration as used for Claude Desktop: 127 | 128 | ```json 129 | { 130 | "mcpServers": { 131 | "rhino": { 132 | "command": "/Users/Joo/miniconda3/envs/rhino_mcp/bin/python", 133 | "args": [ 134 | "-m", 135 | "rhino_mcp.server" 136 | ] 137 | } 138 | } 139 | } 140 | ``` 141 | 142 | 143 | > **Important Note:** For both Claude Desktop and Cursor IDE, if you're using a conda environment, you must specify the full path to the Python interpreter as shown above. Using relative paths or commands like `python` or `uvx` might not work properly with conda environments. 144 | 145 | ### Setting up Replicate Integration 146 | 147 | 1. Create a `.env` file in the root directory of the project 148 | 2. Add your Replicate API token: 149 | ``` 150 | REPLICATE_API_TOKEN=your_token_here 151 | ``` 152 | 3. Make sure to keep this file private and never commit it to version control 153 | 154 | ### Grasshopper Integration 155 | 156 | The package includes enhanced Grasshopper integration: 157 | 158 | 1. Start the Grasshopper server component (in rhino_mcp/grasshopper_mcp_client.gh) 159 | 160 | 161 | 162 | Key features: 163 | - Non-blocking communication via HTTP 164 | - Support for external Python file references 165 | - error handling and feedback 166 | 167 | 168 | ### Example Commands 169 | 170 | Here are some examples of what you can ask Claude to do: 171 | 172 | - "Get information about the current Rhino scene" 173 | - "Create a cube at the origin" 174 | - "Get all layers in the Rhino document" 175 | - "Execute this Python code in Rhino: ..." 176 | - "Update a Grasshopper script component with new code" 177 | - "Link a Grasshopper component to an external Python file" 178 | - "Get the current state of selected Grasshopper components" 179 | 180 | ## Troubleshooting 181 | 182 | - **Connection issues**: 183 | - Make sure the Rhino script is running (check Python Editor output) 184 | - Verify port 9876 is not in use by another application 185 | - Check that both Rhino and Claude Desktop are running 186 | 187 | - **Script not starting**: 188 | - Make sure you're using Rhino 7 or newer 189 | - Check the Python Editor for any error messages 190 | - Try closing and reopening the Python Editor 191 | 192 | - **Package not found**: 193 | - Ensure you're in the correct directory and have installed the package in development mode 194 | - Verify your conda environment is activated 195 | 196 | - **Python path issues**: 197 | - Verify that the Python path in `claude_desktop_config.json` matches your conda environment's Python path 198 | - Make sure you're using the full path to the Python interpreter 199 | 200 | ## Limitations 201 | 202 | - The `execute_rhino_code` and `execute_code_in_gh` tools allow running arbitrary Python code, which can be powerful but potentially dangerous. Use with caution. 203 | - Grasshopper integration requires the HTTP server component to be running 204 | - External code references in Grasshopper require careful file path management 205 | - This is a minimal implementation focused on basic functionality. Advanced features may require additional development. 206 | 207 | ## Extending 208 | 209 | To add new functionality, you need to: 210 | 211 | 1. Add new command handlers and functions in `rhino_script.py` and the `RhinoMCPServer` class. 212 | 2. Add corresponding MCP tools in `server.py` that include tool and arg descriptions 213 | 214 | ## License 215 | 216 | This project is open source and available under the MIT License. -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp/server.py: -------------------------------------------------------------------------------- 1 | """Rhino integration through the Model Context Protocol.""" 2 | from mcp.server.fastmcp import FastMCP, Context, Image 3 | import logging 4 | import os 5 | from dataclasses import dataclass 6 | from contextlib import asynccontextmanager 7 | from typing import AsyncIterator, Dict, Any, List, Optional 8 | import json 9 | import io 10 | from PIL import Image as PILImage 11 | from pathlib import Path 12 | 13 | # Try to load environment variables from .env file 14 | try: 15 | from dotenv import load_dotenv 16 | env_path = Path(__file__).parent.parent / '.env' 17 | if env_path.exists(): 18 | load_dotenv(dotenv_path=env_path) 19 | logging.info(f"Loaded environment variables from {env_path}") 20 | except ImportError: 21 | logging.warning("python-dotenv not installed. Install it to use .env files: pip install python-dotenv") 22 | 23 | # Import our tool modules 24 | from .replicate_tools import ReplicateTools 25 | from .rhino_tools import RhinoTools, get_rhino_connection 26 | from .grasshopper_tools import GrasshopperTools, get_grasshopper_connection 27 | from .utility_tools import UtilityTools 28 | 29 | # Configure logging 30 | logging.basicConfig(level=logging.INFO, 31 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') 32 | logger = logging.getLogger("RhinoMCPServer") 33 | 34 | @asynccontextmanager 35 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: 36 | """Manage server startup and shutdown lifecycle""" 37 | rhino_conn = None 38 | gh_conn = None 39 | 40 | try: 41 | logger.info("RhinoMCP server starting up") 42 | 43 | # Try to connect to Rhino script 44 | try: 45 | rhino_conn = get_rhino_connection() 46 | rhino_conn.connect() 47 | logger.info("Successfully connected to Rhino script") 48 | except Exception as e: 49 | logger.warning("Could not connect to Rhino script: {0}".format(str(e))) 50 | 51 | # Try to connect to Grasshopper script 52 | try: 53 | gh_conn = get_grasshopper_connection() 54 | # Just check if the server is available - don't connect yet 55 | if gh_conn.check_server_available(): 56 | logger.info("Grasshopper server is available") 57 | else: 58 | logger.warning("Grasshopper server is not available. Start the GHPython component in Grasshopper to enable Grasshopper integration.") 59 | except Exception as e: 60 | logger.warning("Error checking Grasshopper server availability: {0}".format(str(e))) 61 | 62 | yield {} 63 | finally: 64 | logger.info("RhinoMCP server shut down") 65 | 66 | # Clean up connections 67 | if rhino_conn: 68 | try: 69 | rhino_conn.disconnect() 70 | logger.info("Disconnected from Rhino script") 71 | except Exception as e: 72 | logger.warning("Error disconnecting from Rhino: {0}".format(str(e))) 73 | 74 | if gh_conn: 75 | try: 76 | gh_conn.disconnect() 77 | logger.info("Disconnected from Grasshopper script") 78 | except Exception as e: 79 | logger.warning("Error disconnecting from Grasshopper: {0}".format(str(e))) 80 | 81 | # Create the MCP server with lifespan support 82 | app = FastMCP( 83 | "RhinoMCP", 84 | description="Rhino integration through the Model Context Protocol", 85 | lifespan=server_lifespan 86 | ) 87 | 88 | # Initialize tool collections 89 | replicate_tools = ReplicateTools(app) 90 | rhino_tools = RhinoTools(app) 91 | grasshopper_tools = GrasshopperTools(app) 92 | utility_tools = UtilityTools(app) 93 | 94 | @app.prompt() 95 | def rhino_creation_strategy() -> str: 96 | """Defines the preferred strategy for creating and managing objects in Rhino""" 97 | return """When working with Rhino through MCP, follow these guidelines: 98 | 99 | Especially when working with geometry, iterate with smaller steps and check the scene state from time to time. 100 | Act strategically with a long-term plan, think about how to organize the data and scene objects in a way that is easy to maintain and extend, by using layers and metadata (name, description), 101 | with the get_objects_with_metadata() function you can filter and select objects based on this metadata. You can access objects, and with the "type" attribute you can check their geometry type and 102 | access the geometry specific properties (such as corner points etc.) to create more complex scenes with spatial consistency. Start from sparse to detail (e.g. first the building plot, then the wall, then the window etc. - it is crucial to use metadata to be able to do that) 103 | 104 | 1. Scene Context Awareness: 105 | - Always start by checking the scene using get_scene_info() for basic overview 106 | - Use the capture_viewport to get an image from viewport to get a quick overview of the scene 107 | - Use get_objects_with_metadata() for detailed object information and filtering 108 | - The short_id in metadata can be displayed in viewport using capture_viewport() 109 | 110 | 2. Object Creation and Management: 111 | - When creating objects, ALWAYS call add_object_metadata() after creation (The add_object_metadata() function is provided in the code context) 112 | - Use meaningful names for objects to help with you with later identification, organize the scenes with layers (but not too many layers) 113 | - Think about grouping objects (e.g. two planes that form a window) 114 | 115 | 3. Always check the bbox for each item so that (it's stored as list of points in the metadata under the key "bbox"): 116 | - Ensure that all objects that should not be clipping are not clipping. 117 | - Items have the right spatial relationship. 118 | 119 | 4. Code Execution: 120 | - This is Rhino 7 with IronPython 2.7 - no f-strings or modern Python features etc 121 | - DONT FORGET NO f-strings! No f-strings, No f-strings! 122 | - Prefer automated solutions over user interaction, unless its requested or it makes sense or you struggle with errors 123 | - You can use rhino command syntax to ask the user questions e.g. "should i do "A" or "B"" where A,B are clickable options 124 | 125 | 5. Best Practices: 126 | - Keep objects organized in appropriate layers 127 | - Use meaningful names and descriptions 128 | - Use viewport captures to verify visual results 129 | """ 130 | 131 | @app.prompt() 132 | def grasshopper_usage_strategy() -> str: 133 | """Defines the preferred strategy for working with Grasshopper through MCP""" 134 | return """When working with Grasshopper through MCP, follow these guidelines: 135 | 136 | 2. Document Exploration: 137 | - Use get_gh_context() to get an overview of all components and their connections 138 | - Use simplified=True for basic info, False for detailed properties (otherwise it get get very very long) 139 | - Use get_selected() to examine currently selected components 140 | - Use get_objects() with GUIDs to examine specific components 141 | - Use context_depth parameter (0-3) to include connected components in the response 142 | 143 | 3. Script Component Management: 144 | - Use update_script() to modify existing script components: 145 | - Only use proper IronPython 2.7 syntax (no f-strigns) 146 | - Add descriptions for better documentation 147 | - Include user messages to explain changes or suggest next steps 148 | - Define or modify component parameters: 149 | * When changing parameters, provide ALL desired parameters (even existing ones) 150 | * Specify input/output type, name, and optional properties 151 | * Consider access type (item/list/tree) for inputs 152 | * Be aware that changing parameters may affect existing connections 153 | 154 | 4. Code Execution Guidelines: 155 | - Always use IronPython 2.7 compatible code (no f-strings, walrus operator, etc.) 156 | - Include all required imports in your code 157 | - Use 'result = value' instead of 'return value' to return data as component output 158 | - You can create Grasshopper components via code 159 | - You can access Rhino objects by referencing them 160 | 161 | 5. Rhino Integration: 162 | - Grasshopper is closely integrated with Rhino 163 | - Grasshopper-generated geometry appears in Rhino viewport 164 | 165 | 6. Best Practices: 166 | - remember the Guid / Uuid of components you want to modify / need regular access to 167 | - work in small steps, break down the problem into smaller parts 168 | - Keep script components well-documented with clear descriptions 169 | - Use meaningful names for components and parameters 170 | - Test changes incrementally to ensure stability 171 | - When modifying scripts: 172 | * If not changing parameters, maintain existing input/output structure 173 | * If changing parameters, carefully plan the new parameter set 174 | * Document parameter changes in the user message 175 | """ 176 | 177 | def main(): 178 | """Run the MCP server""" 179 | app.run(transport='stdio') 180 | 181 | if __name__ == "__main__": 182 | main() -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp/rhino_tools.py: -------------------------------------------------------------------------------- 1 | """Tools for interacting with Rhino through socket connection.""" 2 | from mcp.server.fastmcp import FastMCP, Context, Image 3 | import logging 4 | from dataclasses import dataclass 5 | from contextlib import asynccontextmanager 6 | from typing import AsyncIterator, Dict, Any, List, Optional 7 | import json 8 | import socket 9 | import time 10 | import base64 11 | import io 12 | from PIL import Image as PILImage 13 | 14 | 15 | # Configure logging 16 | logger = logging.getLogger("RhinoTools") 17 | 18 | class RhinoConnection: 19 | def __init__(self, host='localhost', port=9876): 20 | self.host = host 21 | self.port = port 22 | self.socket = None 23 | self.timeout = 30.0 # 30 second timeout 24 | self.buffer_size = 14485760 # 10MB buffer size for handling large images 25 | 26 | def connect(self): 27 | """Connect to the Rhino script's socket server""" 28 | if self.socket is None: 29 | try: 30 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 31 | self.socket.settimeout(self.timeout) 32 | self.socket.connect((self.host, self.port)) 33 | logger.info("Connected to Rhino script") 34 | except Exception as e: 35 | logger.error("Failed to connect to Rhino script: {0}".format(str(e))) 36 | self.disconnect() 37 | raise 38 | 39 | def disconnect(self): 40 | """Disconnect from the Rhino script""" 41 | if self.socket: 42 | try: 43 | self.socket.close() 44 | except: 45 | pass 46 | self.socket = None 47 | 48 | def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: 49 | """Send a command to the Rhino script and wait for response""" 50 | if self.socket is None: 51 | self.connect() 52 | 53 | try: 54 | # Prepare command 55 | command = { 56 | "type": command_type, 57 | "params": params or {} 58 | } 59 | 60 | # Send command 61 | command_json = json.dumps(command) 62 | logger.info("Sending command: {0}".format(command_json)) 63 | self.socket.sendall(command_json.encode('utf-8')) 64 | 65 | # Receive response with timeout and larger buffer 66 | buffer = b'' 67 | start_time = time.time() 68 | 69 | while True: 70 | try: 71 | # Check timeout 72 | if time.time() - start_time > self.timeout: 73 | raise Exception("Response timeout after {0} seconds".format(self.timeout)) 74 | 75 | # Receive data 76 | data = self.socket.recv(self.buffer_size) 77 | if not data: 78 | break 79 | 80 | buffer += data 81 | logger.debug("Received {0} bytes of data".format(len(data))) 82 | 83 | # Try to parse JSON 84 | try: 85 | response = json.loads(buffer.decode('utf-8')) 86 | logger.info("Received complete response: {0}".format(response)) 87 | 88 | # Check for error response 89 | if response.get("status") == "error": 90 | raise Exception(response.get("message", "Unknown error from Rhino")) 91 | 92 | return response 93 | except json.JSONDecodeError: 94 | # If we have a complete response, it should be valid JSON 95 | if len(buffer) > 0: 96 | continue 97 | else: 98 | raise Exception("Invalid JSON response from Rhino") 99 | 100 | except socket.timeout: 101 | raise Exception("Socket timeout while receiving response") 102 | 103 | raise Exception("Connection closed by Rhino script") 104 | 105 | except Exception as e: 106 | logger.error("Error communicating with Rhino script: {0}".format(str(e))) 107 | self.disconnect() # Disconnect on error to force reconnection 108 | raise 109 | 110 | # Global connection instance 111 | _rhino_connection = None 112 | 113 | def get_rhino_connection() -> RhinoConnection: 114 | """Get or create the Rhino connection""" 115 | global _rhino_connection 116 | if _rhino_connection is None: 117 | _rhino_connection = RhinoConnection() 118 | return _rhino_connection 119 | 120 | class RhinoTools: 121 | """Collection of tools for interacting with Rhino.""" 122 | 123 | def __init__(self, app): 124 | self.app = app 125 | self._register_tools() 126 | 127 | def _register_tools(self): 128 | """Register all Rhino tools with the MCP server.""" 129 | self.app.tool()(self.get_scene_info) 130 | self.app.tool()(self.get_layers) 131 | self.app.tool()(self.get_scene_objects_with_metadata) 132 | self.app.tool()(self.capture_viewport) 133 | self.app.tool()(self.execute_rhino_code) 134 | 135 | def get_scene_info(self, ctx: Context) -> str: 136 | """Get basic information about the current Rhino scene. 137 | 138 | This is a lightweight function that returns basic scene information: 139 | - List of all layers with basic information about the layer and 5 sample objects with their metadata 140 | - No metadata or detailed properties 141 | - Use this for quick scene overview or when you only need basic object information 142 | 143 | Returns: 144 | JSON string containing basic scene information 145 | """ 146 | try: 147 | connection = get_rhino_connection() 148 | result = connection.send_command("get_scene_info") 149 | return json.dumps(result, indent=2) 150 | except Exception as e: 151 | logger.error("Error getting scene info from Rhino: {0}".format(str(e))) 152 | return "Error getting scene info: {0}".format(str(e)) 153 | 154 | def get_layers(self, ctx: Context) -> str: 155 | """Get list of layers in Rhino""" 156 | try: 157 | connection = get_rhino_connection() 158 | result = connection.send_command("get_layers") 159 | return json.dumps(result, indent=2) 160 | except Exception as e: 161 | logger.error("Error getting layers from Rhino: {0}".format(str(e))) 162 | return "Error getting layers: {0}".format(str(e)) 163 | 164 | def get_scene_objects_with_metadata(self, ctx: Context, filters: Optional[Dict[str, Any]] = None, metadata_fields: Optional[List[str]] = None) -> str: 165 | """Get detailed information about objects in the scene with their metadata. 166 | 167 | This is a CORE FUNCTION for scene context awareness. It provides: 168 | 1. Full metadata for each object we created via this mcp connection including: 169 | - short_id (DDHHMMSS format), can be dispalyed in the viewport when using capture_viewport, can help yo uto visually identify the a object and find it with this function 170 | - created_at timestamp 171 | - layer - layer path 172 | - type - geometry type 173 | - bbox - the bounding box as lsit of points 174 | - name - the name you assigned 175 | - description - description yo uasigned 176 | 177 | 2. Advanced filtering capabilities: 178 | - layer: Filter by layer name (supports wildcards, e.g., "Layer*") 179 | - name: Filter by object name (supports wildcards, e.g., "Cube*") 180 | - short_id: Filter by exact short ID match 181 | 182 | 3. Field selection: 183 | - Can specify which metadata fields to return 184 | - Useful for reducing response size when only certain fields are needed 185 | 186 | Args: 187 | filters: Optional dictionary of filters to apply 188 | metadata_fields: Optional list of specific metadata fields to return 189 | 190 | Returns: 191 | JSON string containing filtered objects with their metadata 192 | """ 193 | try: 194 | connection = get_rhino_connection() 195 | result = connection.send_command("get_objects_with_metadata", { 196 | "filters": filters or {}, 197 | "metadata_fields": metadata_fields 198 | }) 199 | return json.dumps(result, indent=2) 200 | except Exception as e: 201 | logger.error("Error getting objects with metadata: {0}".format(str(e))) 202 | return "Error getting objects with metadata: {0}".format(str(e)) 203 | 204 | def capture_viewport(self, ctx: Context, layer: Optional[str] = None, show_annotations: bool = True, max_size: int = 800) -> Image: 205 | """Capture the current viewport as an image. 206 | 207 | Args: 208 | layer: Optional layer name to filter annotations 209 | show_annotations: Whether to show object annotations, this will display the short_id of the object in the viewport you can use the short_id to select specific objects with the get_objects_with_metadata function 210 | 211 | Returns: 212 | An MCP Image object containing the viewport capture 213 | """ 214 | try: 215 | connection = get_rhino_connection() 216 | result = connection.send_command("capture_viewport", { 217 | "layer": layer, 218 | "show_annotations": show_annotations, 219 | "max_size": max_size 220 | }) 221 | 222 | if result.get("type") == "image": 223 | # Get base64 data from Rhino 224 | base64_data = result["source"]["data"] 225 | 226 | # Convert base64 to bytes 227 | image_bytes = base64.b64decode(base64_data) 228 | 229 | # Create PIL Image from bytes 230 | img = PILImage.open(io.BytesIO(image_bytes)) 231 | 232 | # Convert to PNG format for better quality and consistency 233 | png_buffer = io.BytesIO() 234 | img.save(png_buffer, format="PNG") 235 | png_bytes = png_buffer.getvalue() 236 | 237 | # Return as MCP Image object 238 | return Image(data=png_bytes, format="png") 239 | 240 | else: 241 | raise Exception(result.get("text", "Failed to capture viewport")) 242 | 243 | except Exception as e: 244 | logger.error("Error capturing viewport: {0}".format(str(e))) 245 | raise 246 | 247 | def execute_rhino_code(self, ctx: Context, code: str) -> str: 248 | """Execute arbitrary Python code in Rhino. 249 | 250 | IMPORTANT NOTES FOR CODE EXECUTION: 251 | 0. DONT FORGET NO f-strings! No f-strings, No f-strings! 252 | 1. This is Rhino 7 with IronPython 2.7 - no f-strings or modern Python features 253 | 3. When creating objects, ALWAYS call add_object_metadata(name, description) after creation 254 | 4. For user interaction, you can use RhinoCommon syntax (selected_objects = rs.GetObjects("Please select some objects") etc.) prompted the suer what to do 255 | but prefer automated solutions unless user interaction is specifically requested 256 | 257 | The add_object_metadata() function is provided in the code context and must be called 258 | after creating any object. It adds standardized metadata including: 259 | - name (provided by you) 260 | - description (provided by you) 261 | The metadata helps you to identify and select objects later in the scene and stay organised. 262 | 263 | Common Syntax Errors to Avoid: 264 | 2. No walrus operator (:=) 265 | 3. No type hints 266 | 4. No modern Python features (match/case, etc.) 267 | 5. No list/dict comprehensions with multiple for clauses 268 | 6. No assignment expressions in if/while conditions 269 | 270 | Example of proper object creation: 271 | <<>> 278 | 279 | DONT FORGET NO f-strings! No f-strings, No f-strings! 280 | """ 281 | try: 282 | code_template = """ 283 | import rhinoscriptsyntax as rs 284 | import scriptcontext as sc 285 | import json 286 | import time 287 | from datetime import datetime 288 | 289 | def add_object_metadata(obj_id, name=None, description=None): 290 | \"\"\"Add standardized metadata to an object\"\"\" 291 | try: 292 | # Generate short ID 293 | short_id = datetime.now().strftime("%d%H%M%S") 294 | 295 | # Get bounding box 296 | bbox = rs.BoundingBox(obj_id) 297 | bbox_data = [[p.X, p.Y, p.Z] for p in bbox] if bbox else [] 298 | 299 | # Get object type 300 | obj = sc.doc.Objects.Find(obj_id) 301 | obj_type = obj.Geometry.GetType().Name if obj else "Unknown" 302 | 303 | # Standard metadata 304 | metadata = { 305 | "short_id": short_id, 306 | "created_at": time.time(), 307 | "layer": rs.ObjectLayer(obj_id), 308 | "type": obj_type, 309 | "bbox": bbox_data 310 | } 311 | 312 | # User-provided metadata 313 | if name: 314 | rs.ObjectName(obj_id, name) 315 | metadata["name"] = name 316 | else: 317 | auto_name = "{0}_{1}".format(obj_type, short_id) 318 | rs.ObjectName(obj_id, auto_name) 319 | metadata["name"] = auto_name 320 | 321 | if description: 322 | metadata["description"] = description 323 | 324 | # Store metadata as user text 325 | user_text_data = metadata.copy() 326 | user_text_data["bbox"] = json.dumps(bbox_data) 327 | 328 | for key, value in user_text_data.items(): 329 | rs.SetUserText(obj_id, key, str(value)) 330 | 331 | return {"status": "success"} 332 | except Exception as e: 333 | return {"status": "error", "message": str(e)} 334 | 335 | """ + code 336 | logger.info("Sending code execution request to Rhino") 337 | connection = get_rhino_connection() 338 | result = connection.send_command("execute_code", {"code": code_template}) 339 | 340 | logger.info("Received response from Rhino: {0}".format(result)) 341 | 342 | # Simplified error handling 343 | if result.get("status") == "error": 344 | error_msg = "Error: {0}".format(result.get("message", "Unknown error")) 345 | logger.error("Code execution error: {0}".format(error_msg)) 346 | return error_msg 347 | else: 348 | response = result.get("result", "Code executed successfully") 349 | logger.info("Code execution successful: {0}".format(response)) 350 | return response 351 | 352 | except Exception as e: 353 | error_msg = "Error executing code: {0}".format(str(e)) 354 | logger.error(error_msg) 355 | return error_msg -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Rhino MCP - Rhino-side Script 3 | Handles communication with external MCP server and executes Rhino commands. 4 | """ 5 | 6 | import socket 7 | import threading 8 | import json 9 | import time 10 | import System 11 | import Rhino 12 | import scriptcontext as sc 13 | import rhinoscriptsyntax as rs 14 | import os 15 | import platform 16 | import traceback 17 | import sys 18 | import base64 19 | from System.Drawing import Bitmap 20 | from System.Drawing.Imaging import ImageFormat 21 | from System.IO import MemoryStream 22 | from datetime import datetime 23 | 24 | # Configuration 25 | HOST = 'localhost' 26 | PORT = 9876 27 | 28 | # Add constant for annotation layer 29 | ANNOTATION_LAYER = "MCP_Annotations" 30 | 31 | VALID_METADATA_FIELDS = { 32 | 'required': ['id', 'name', 'type', 'layer'], 33 | 'optional': [ 34 | 'short_id', # Short identifier (DDHHMMSS format) 35 | 'created_at', # Timestamp of creation 36 | 'bbox', # Bounding box coordinates 37 | 'description', # Object description 38 | 'user_text' # All user text key-value pairs 39 | ] 40 | } 41 | 42 | def get_log_dir(): 43 | """Get the appropriate log directory based on the platform""" 44 | home_dir = os.path.expanduser("~") 45 | 46 | # Platform-specific log directory 47 | if platform.system() == "Darwin": # macOS 48 | log_dir = os.path.join(home_dir, "Library", "Application Support", "RhinoMCP", "logs") 49 | elif platform.system() == "Windows": 50 | log_dir = os.path.join(home_dir, "AppData", "Local", "RhinoMCP", "logs") 51 | else: # Linux and others 52 | log_dir = os.path.join(home_dir, ".rhino_mcp", "logs") 53 | 54 | return log_dir 55 | 56 | def log_message(message): 57 | """Log a message to both Rhino's command line and log file""" 58 | # Print to Rhino's command line 59 | Rhino.RhinoApp.WriteLine(message) 60 | 61 | # Log to file 62 | try: 63 | log_dir = get_log_dir() 64 | if not os.path.exists(log_dir): 65 | os.makedirs(log_dir) 66 | 67 | log_file = os.path.join(log_dir, "rhino_mcp.log") 68 | 69 | # Log platform info on first run 70 | if not os.path.exists(log_file): 71 | with open(log_file, "w") as f: 72 | f.write("=== RhinoMCP Log ===\n") 73 | f.write("Platform: {0}\n".format(platform.system())) 74 | f.write("Python Version: {0}\n".format(sys.version)) 75 | f.write("Rhino Version: {0}\n".format(Rhino.RhinoApp.Version)) 76 | f.write("==================\n\n") 77 | 78 | with open(log_file, "a") as f: 79 | timestamp = time.strftime("%Y-%m-%d %H:%M:%S") 80 | f.write("[{0}] {1}\n".format(timestamp, message)) 81 | except Exception as e: 82 | Rhino.RhinoApp.WriteLine("Failed to write to log file: {0}".format(str(e))) 83 | 84 | class RhinoMCPServer: 85 | def __init__(self, host='localhost', port=9876): 86 | self.host = host 87 | self.port = port 88 | self.running = False 89 | self.socket = None 90 | self.server_thread = None 91 | 92 | def start(self): 93 | if self.running: 94 | log_message("Server is already running") 95 | return 96 | 97 | self.running = True 98 | 99 | try: 100 | # Create socket 101 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 102 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 103 | self.socket.bind((self.host, self.port)) 104 | self.socket.listen(1) 105 | 106 | # Start server thread 107 | self.server_thread = threading.Thread(target=self._server_loop) 108 | self.server_thread.daemon = True 109 | self.server_thread.start() 110 | 111 | log_message("RhinoMCP server started on {0}:{1}".format(self.host, self.port)) 112 | except Exception as e: 113 | log_message("Failed to start server: {0}".format(str(e))) 114 | self.stop() 115 | 116 | def stop(self): 117 | self.running = False 118 | 119 | # Close socket 120 | if self.socket: 121 | try: 122 | self.socket.close() 123 | except: 124 | pass 125 | self.socket = None 126 | 127 | # Wait for thread to finish 128 | if self.server_thread: 129 | try: 130 | if self.server_thread.is_alive(): 131 | self.server_thread.join(timeout=1.0) 132 | except: 133 | pass 134 | self.server_thread = None 135 | 136 | log_message("RhinoMCP server stopped") 137 | 138 | def _server_loop(self): 139 | """Main server loop that accepts connections""" 140 | while self.running: 141 | try: 142 | client, addr = self.socket.accept() 143 | log_message("Client connected from {0}:{1}".format(addr[0], addr[1])) 144 | 145 | # Handle client in a new thread 146 | client_thread = threading.Thread(target=self._handle_client, args=(client,)) 147 | client_thread.daemon = True 148 | client_thread.start() 149 | 150 | except Exception as e: 151 | if self.running: 152 | log_message("Error accepting connection: {0}".format(str(e))) 153 | time.sleep(0.5) 154 | 155 | def _handle_client(self, client): 156 | """Handle a client connection""" 157 | try: 158 | # Set socket buffer size 159 | client.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 14485760) # 10MB 160 | client.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 14485760) # 10MB 161 | 162 | while self.running: 163 | # Receive command with larger buffer 164 | data = client.recv(14485760) # 10MB buffer 165 | if not data: 166 | log_message("Client disconnected") 167 | break 168 | 169 | try: 170 | command = json.loads(data.decode('utf-8')) 171 | log_message("Received command: {0}".format(command)) 172 | 173 | # Create a closure to capture the client connection 174 | def execute_wrapper(): 175 | try: 176 | response = self.execute_command(command) 177 | response_json = json.dumps(response) 178 | # Split large responses into chunks if needed 179 | chunk_size = 14485760 # 10MB chunks 180 | response_bytes = response_json.encode('utf-8') 181 | for i in range(0, len(response_bytes), chunk_size): 182 | chunk = response_bytes[i:i + chunk_size] 183 | client.sendall(chunk) 184 | log_message("Response sent successfully") 185 | except Exception as e: 186 | log_message("Error executing command: {0}".format(str(e))) 187 | traceback.print_exc() 188 | error_response = { 189 | "status": "error", 190 | "message": str(e) 191 | } 192 | try: 193 | client.sendall(json.dumps(error_response).encode('utf-8')) 194 | except Exception as e: 195 | log_message("Failed to send error response: {0}".format(str(e))) 196 | return False # Signal connection should be closed 197 | return True # Signal connection should stay open 198 | 199 | # Use RhinoApp.Idle event for IronPython 2.7 compatibility 200 | def idle_handler(sender, e): 201 | if not execute_wrapper(): 202 | # If execute_wrapper returns False, close the connection 203 | try: 204 | client.close() 205 | except: 206 | pass 207 | # Remove the handler after execution 208 | Rhino.RhinoApp.Idle -= idle_handler 209 | 210 | Rhino.RhinoApp.Idle += idle_handler 211 | 212 | except ValueError as e: 213 | # Handle JSON decode error (IronPython 2.7) 214 | log_message("Invalid JSON received: {0}".format(str(e))) 215 | error_response = { 216 | "status": "error", 217 | "message": "Invalid JSON format" 218 | } 219 | try: 220 | client.sendall(json.dumps(error_response).encode('utf-8')) 221 | except: 222 | break # Close connection on send error 223 | 224 | except Exception as e: 225 | log_message("Error handling client: {0}".format(str(e))) 226 | traceback.print_exc() 227 | finally: 228 | try: 229 | client.close() 230 | except: 231 | pass 232 | 233 | def execute_command(self, command): 234 | """Execute a command received from the client""" 235 | try: 236 | command_type = command.get("type") 237 | params = command.get("params", {}) 238 | 239 | if command_type == "get_scene_info": 240 | return self._get_scene_info(params) 241 | elif command_type == "create_cube": 242 | return self._create_cube(params) 243 | elif command_type == "get_layers": 244 | return self._get_layers() 245 | elif command_type == "execute_code": 246 | return self._execute_code(params) 247 | elif command_type == "get_objects_with_metadata": 248 | return self._get_objects_with_metadata(params) 249 | elif command_type == "capture_viewport": 250 | return self._capture_viewport(params) 251 | elif command_type == "add_metadata": 252 | return self._add_object_metadata( 253 | params.get("object_id"), 254 | params.get("name"), 255 | params.get("description") 256 | ) 257 | else: 258 | return {"status": "error", "message": "Unknown command type"} 259 | 260 | except Exception as e: 261 | log_message("Error executing command: {0}".format(str(e))) 262 | traceback.print_exc() 263 | return {"status": "error", "message": str(e)} 264 | 265 | def _get_scene_info(self, params=None): 266 | """Get simplified scene information focusing on layers and example objects""" 267 | try: 268 | doc = sc.doc 269 | if not doc: 270 | return { 271 | "status": "error", 272 | "message": "No active document" 273 | } 274 | 275 | log_message("Getting simplified scene info...") 276 | layers_info = [] 277 | 278 | for layer in doc.Layers: 279 | layer_objects = [obj for obj in doc.Objects if obj.Attributes.LayerIndex == layer.Index] 280 | example_objects = [] 281 | 282 | for obj in layer_objects[:5]: # Limit to 5 example objects per layer 283 | try: 284 | # Convert NameValueCollection to dictionary 285 | user_strings = {} 286 | if obj.Attributes.GetUserStrings(): 287 | for key in obj.Attributes.GetUserStrings(): 288 | user_strings[key] = obj.Attributes.GetUserString(key) 289 | 290 | obj_info = { 291 | "id": str(obj.Id), 292 | "name": obj.Name or "Unnamed", 293 | "type": obj.Geometry.GetType().Name if obj.Geometry else "Unknown", 294 | "metadata": user_strings # Now using the converted dictionary 295 | } 296 | example_objects.append(obj_info) 297 | except Exception as e: 298 | log_message("Error processing object: {0}".format(str(e))) 299 | continue 300 | 301 | layer_info = { 302 | "full_path": layer.FullPath, 303 | "object_count": len(layer_objects), 304 | "is_visible": layer.IsVisible, 305 | "is_locked": layer.IsLocked, 306 | "example_objects": example_objects 307 | } 308 | layers_info.append(layer_info) 309 | 310 | response = { 311 | "status": "success", 312 | "layers": layers_info 313 | } 314 | 315 | log_message("Simplified scene info collected successfully") 316 | return response 317 | 318 | except Exception as e: 319 | log_message("Error getting simplified scene info: {0}".format(str(e))) 320 | return { 321 | "status": "error", 322 | "message": str(e), 323 | "layers": [] 324 | } 325 | 326 | def _create_cube(self, params): 327 | """Create a cube in the scene""" 328 | try: 329 | size = float(params.get("size", 1.0)) 330 | location = params.get("location", [0, 0, 0]) 331 | name = params.get("name", "Cube") 332 | 333 | # Create cube using RhinoCommon 334 | box = Rhino.Geometry.Box( 335 | Rhino.Geometry.Plane.WorldXY, 336 | Rhino.Geometry.Interval(0, size), 337 | Rhino.Geometry.Interval(0, size), 338 | Rhino.Geometry.Interval(0, size) 339 | ) 340 | 341 | # Move to specified location 342 | transform = Rhino.Geometry.Transform.Translation( 343 | location[0] - box.Center.X, 344 | location[1] - box.Center.Y, 345 | location[2] - box.Center.Z 346 | ) 347 | box.Transform(transform) 348 | 349 | # Add to document 350 | id = sc.doc.Objects.AddBox(box) 351 | if id != System.Guid.Empty: 352 | obj = sc.doc.Objects.Find(id) 353 | if obj: 354 | obj.Name = name 355 | sc.doc.Views.Redraw() 356 | return { 357 | "status": "success", 358 | "message": "Created cube with size {0}".format(size), 359 | "id": str(id) 360 | } 361 | 362 | return {"status": "error", "message": "Failed to create cube"} 363 | except Exception as e: 364 | return {"status": "error", "message": str(e)} 365 | 366 | def _get_layers(self): 367 | """Get information about all layers""" 368 | try: 369 | doc = sc.doc 370 | layers = [] 371 | 372 | for layer in doc.Layers: 373 | layers.append({ 374 | "id": layer.Index, 375 | "name": layer.Name, 376 | "object_count": layer.ObjectCount, 377 | "is_visible": layer.IsVisible, 378 | "is_locked": layer.IsLocked 379 | }) 380 | 381 | return { 382 | "status": "success", 383 | "layers": layers 384 | } 385 | except Exception as e: 386 | return {"status": "error", "message": str(e)} 387 | 388 | def _execute_code(self, params): 389 | """Execute arbitrary Python code""" 390 | try: 391 | code = params.get("code", "") 392 | if not code: 393 | return {"status": "error", "message": "No code provided"} 394 | 395 | log_message("Executing code: {0}".format(code)) 396 | 397 | # Create a new scope for code execution 398 | local_dict = {} 399 | 400 | try: 401 | # Execute the code 402 | exec(code, globals(), local_dict) 403 | 404 | # Get result from local_dict or use a default message 405 | result = local_dict.get("result", "Code executed successfully") 406 | log_message("Code execution completed. Result: {0}".format(result)) 407 | 408 | response = { 409 | "status": "success", 410 | "result": str(result), 411 | "variables": {k: str(v) for k, v in local_dict.items() if not k.startswith('__')} 412 | } 413 | 414 | log_message("Sending response: {0}".format(json.dumps(response))) 415 | return response 416 | 417 | except Exception as e: 418 | hint = "Did you use f-string formatting? You have to use IronPython here that doesn't support this." 419 | error_response = { 420 | "status": "error", 421 | "message": "{0} {1}".format(hint, str(e)), 422 | } 423 | log_message("Error: {0}".format(error_response)) 424 | return error_response 425 | 426 | except Exception as e: 427 | hint = "Did you use f-string formatting? You have to use IronPython here that doesn't support this." 428 | error_response = { 429 | "status": "error", 430 | "message": "{0} {1}".format(hint, str(e)), 431 | } 432 | log_message("System error: {0}".format(error_response)) 433 | return error_response 434 | 435 | def _add_object_metadata(self, obj_id, name=None, description=None): 436 | """Add standardized metadata to an object""" 437 | try: 438 | import json 439 | import time 440 | from datetime import datetime 441 | 442 | # Generate short ID 443 | short_id = datetime.now().strftime("%d%H%M%S") 444 | 445 | # Get bounding box 446 | bbox = rs.BoundingBox(obj_id) 447 | bbox_data = [[p.X, p.Y, p.Z] for p in bbox] if bbox else [] 448 | 449 | # Get object type 450 | obj = sc.doc.Objects.Find(obj_id) 451 | obj_type = obj.Geometry.GetType().Name if obj else "Unknown" 452 | 453 | # Standard metadata 454 | metadata = { 455 | "short_id": short_id, 456 | "created_at": time.time(), 457 | "layer": rs.ObjectLayer(obj_id), 458 | "type": obj_type, 459 | "bbox": bbox_data 460 | } 461 | 462 | # User-provided metadata 463 | if name: 464 | rs.ObjectName(obj_id, name) 465 | metadata["name"] = name 466 | else: 467 | # Auto-generate name if none provided 468 | auto_name = "{0}_{1}".format(obj_type, short_id) 469 | rs.ObjectName(obj_id, auto_name) 470 | metadata["name"] = auto_name 471 | 472 | if description: 473 | metadata["description"] = description 474 | 475 | # Store metadata as user text (convert bbox to string for storage) 476 | user_text_data = metadata.copy() 477 | user_text_data["bbox"] = json.dumps(bbox_data) 478 | 479 | # Add all metadata as user text 480 | for key, value in user_text_data.items(): 481 | rs.SetUserText(obj_id, key, str(value)) 482 | 483 | return {"status": "success"} 484 | except Exception as e: 485 | log_message("Error adding metadata: " + str(e)) 486 | return {"status": "error", "message": str(e)} 487 | 488 | def _get_objects_with_metadata(self, params): 489 | """Get objects with their metadata, with optional filtering""" 490 | try: 491 | import re 492 | import json 493 | 494 | filters = params.get("filters", {}) 495 | metadata_fields = params.get("metadata_fields") 496 | layer_filter = filters.get("layer") 497 | name_filter = filters.get("name") 498 | id_filter = filters.get("short_id") 499 | 500 | # Validate metadata fields 501 | all_fields = VALID_METADATA_FIELDS['required'] + VALID_METADATA_FIELDS['optional'] 502 | if metadata_fields: 503 | invalid_fields = [f for f in metadata_fields if f not in all_fields] 504 | if invalid_fields: 505 | return { 506 | "status": "error", 507 | "message": "Invalid metadata fields: " + ", ".join(invalid_fields), 508 | "available_fields": all_fields 509 | } 510 | 511 | objects = [] 512 | 513 | for obj in sc.doc.Objects: 514 | obj_id = obj.Id 515 | 516 | # Apply filters 517 | if layer_filter: 518 | layer = rs.ObjectLayer(obj_id) 519 | pattern = "^" + layer_filter.replace("*", ".*") + "$" 520 | if not re.match(pattern, layer, re.IGNORECASE): 521 | continue 522 | 523 | if name_filter: 524 | name = obj.Name or "" 525 | pattern = "^" + name_filter.replace("*", ".*") + "$" 526 | if not re.match(pattern, name, re.IGNORECASE): 527 | continue 528 | 529 | if id_filter: 530 | short_id = rs.GetUserText(obj_id, "short_id") or "" 531 | if short_id != id_filter: 532 | continue 533 | 534 | # Build base object data with required fields 535 | obj_data = { 536 | "id": str(obj_id), 537 | "name": obj.Name or "Unnamed", 538 | "type": obj.Geometry.GetType().Name, 539 | "layer": rs.ObjectLayer(obj_id) 540 | } 541 | 542 | # Get user text data and parse stored values 543 | stored_data = {} 544 | for key in rs.GetUserText(obj_id): 545 | value = rs.GetUserText(obj_id, key) 546 | if key == "bbox": 547 | try: 548 | value = json.loads(value) 549 | except: 550 | value = [] 551 | elif key == "created_at": 552 | try: 553 | value = float(value) 554 | except: 555 | value = 0 556 | stored_data[key] = value 557 | 558 | # Build metadata based on requested fields 559 | if metadata_fields: 560 | metadata = {k: stored_data[k] for k in metadata_fields if k in stored_data} 561 | else: 562 | metadata = {k: v for k, v in stored_data.items() 563 | if k not in VALID_METADATA_FIELDS['required']} 564 | 565 | # Only include user_text if specifically requested 566 | if not metadata_fields or 'user_text' in metadata_fields: 567 | user_text = {k: v for k, v in stored_data.items() 568 | if k not in metadata} 569 | if user_text: 570 | obj_data["user_text"] = user_text 571 | 572 | # Add metadata if we have any 573 | if metadata: 574 | obj_data["metadata"] = metadata 575 | 576 | objects.append(obj_data) 577 | 578 | return { 579 | "status": "success", 580 | "count": len(objects), 581 | "objects": objects, 582 | "available_fields": all_fields 583 | } 584 | 585 | except Exception as e: 586 | log_message("Error filtering objects: " + str(e)) 587 | return { 588 | "status": "error", 589 | "message": str(e), 590 | "available_fields": all_fields 591 | } 592 | 593 | def _capture_viewport(self, params): 594 | """Capture viewport with optional annotations and layer filtering""" 595 | try: 596 | layer_name = params.get("layer") 597 | show_annotations = params.get("show_annotations", True) 598 | max_size = params.get("max_size", 800) # Default max dimension 599 | original_layer = rs.CurrentLayer() 600 | temp_dots = [] 601 | 602 | if show_annotations: 603 | # Ensure annotation layer exists and is current 604 | if not rs.IsLayer(ANNOTATION_LAYER): 605 | rs.AddLayer(ANNOTATION_LAYER, color=(255, 0, 0)) 606 | rs.CurrentLayer(ANNOTATION_LAYER) 607 | 608 | # Create temporary text dots for each object 609 | for obj in sc.doc.Objects: 610 | if layer_name and rs.ObjectLayer(obj.Id) != layer_name: 611 | continue 612 | 613 | bbox = rs.BoundingBox(obj.Id) 614 | if bbox: 615 | pt = bbox[1] # Use top corner of bounding box 616 | short_id = rs.GetUserText(obj.Id, "short_id") 617 | if not short_id: 618 | short_id = datetime.now().strftime("%d%H%M%S") 619 | rs.SetUserText(obj.Id, "short_id", short_id) 620 | 621 | name = rs.ObjectName(obj.Id) or "Unnamed" 622 | text = "{0}\n{1}".format(name, short_id) 623 | 624 | dot_id = rs.AddTextDot(text, pt) 625 | rs.TextDotHeight(dot_id, 8) 626 | temp_dots.append(dot_id) 627 | 628 | try: 629 | view = sc.doc.Views.ActiveView 630 | memory_stream = MemoryStream() 631 | 632 | # Capture to bitmap 633 | bitmap = view.CaptureToBitmap() 634 | 635 | # Calculate new dimensions while maintaining aspect ratio 636 | width, height = bitmap.Width, bitmap.Height 637 | if width > height: 638 | new_width = max_size 639 | new_height = int(height * (max_size / width)) 640 | else: 641 | new_height = max_size 642 | new_width = int(width * (max_size / height)) 643 | 644 | # Create resized bitmap 645 | resized_bitmap = Bitmap(bitmap, new_width, new_height) 646 | 647 | # Save as JPEG (IronPython doesn't support quality parameter) 648 | resized_bitmap.Save(memory_stream, ImageFormat.Jpeg) 649 | 650 | bytes_array = memory_stream.ToArray() 651 | image_data = base64.b64encode(bytes(bytearray(bytes_array))).decode('utf-8') 652 | 653 | # Clean up 654 | bitmap.Dispose() 655 | resized_bitmap.Dispose() 656 | memory_stream.Dispose() 657 | 658 | finally: 659 | if temp_dots: 660 | rs.DeleteObjects(temp_dots) 661 | rs.CurrentLayer(original_layer) 662 | 663 | return { 664 | "type": "image", 665 | "source": { 666 | "type": "base64", 667 | "media_type": "image/jpeg", 668 | "data": image_data 669 | } 670 | } 671 | 672 | except Exception as e: 673 | log_message("Error capturing viewport: " + str(e)) 674 | if 'original_layer' in locals(): 675 | rs.CurrentLayer(original_layer) 676 | return { 677 | "type": "text", 678 | "text": "Error capturing viewport: " + str(e) 679 | } 680 | 681 | # Create and start server 682 | server = RhinoMCPServer(HOST, PORT) 683 | server.start() 684 | 685 | # Add commands to Rhino 686 | def start_server(): 687 | """Start the RhinoMCP server""" 688 | server.start() 689 | 690 | def stop_server(): 691 | """Stop the RhinoMCP server""" 692 | server.stop() 693 | 694 | # Automatically start the server when this script is loaded 695 | start_server() 696 | log_message("RhinoMCP script loaded. Server started automatically.") 697 | log_message("To stop the server, run: stop_server()") -------------------------------------------------------------------------------- /rhino_mcp/rhino_mcp/grasshopper_tools.py: -------------------------------------------------------------------------------- 1 | """Tools for interacting with Grasshopper through socket connection.""" 2 | from mcp.server.fastmcp import FastMCP, Context, Image 3 | import logging 4 | from typing import Dict, Any, List, Optional, Union 5 | import json 6 | import socket 7 | import time 8 | import base64 9 | import io 10 | from PIL import Image as PILImage 11 | import requests 12 | import re 13 | from urllib3.exceptions import InsecureRequestWarning 14 | import urllib3 15 | 16 | # Disable insecure HTTPS warnings 17 | urllib3.disable_warnings(InsecureRequestWarning) 18 | 19 | # Configure logging 20 | logger = logging.getLogger("GrasshopperTools") 21 | 22 | # Add a preprocessing function for LLM inputs 23 | def preprocess_llm_input(input_str: str) -> str: 24 | """ 25 | Preprocess a potentially malformed JSON string from an LLM. 26 | This handles common issues before attempting JSON parsing. 27 | 28 | Args: 29 | input_str: Raw string from LLM that may contain malformed JSON 30 | 31 | Returns: 32 | Preprocessed string that should be easier to parse 33 | """ 34 | if not isinstance(input_str, str): 35 | return input_str 36 | 37 | # Replace backtick delimiters with proper double quotes for the entire JSON object 38 | if input_str.strip().startswith('`{') and input_str.strip().endswith('}`'): 39 | input_str = input_str.strip()[1:-1] # Remove the outer backticks 40 | 41 | # Handle backtick-delimited field names and string values 42 | # This is a basic approach - first convert all standalone backtick pairs to double quotes 43 | result = "" 44 | in_string = False 45 | last_char = None 46 | i = 0 47 | 48 | while i < len(input_str): 49 | char = input_str[i] 50 | 51 | # Handle backtick as quote 52 | if char == '`' and (last_char is None or last_char != '\\'): 53 | in_string = not in_string 54 | result += '"' 55 | else: 56 | result += char 57 | 58 | last_char = char 59 | i += 1 60 | 61 | # Fix boolean values 62 | result = re.sub(r':\s*True\b', ': true', result) 63 | result = re.sub(r':\s*False\b', ': false', result) 64 | result = re.sub(r':\s*None\b', ': null', result) 65 | 66 | return result 67 | 68 | def extract_payload_fields(raw_input: str) -> Dict[str, Any]: 69 | """ 70 | Extract fields from a payload that might be malformed. 71 | Works with raw LLM output directly. 72 | 73 | Args: 74 | raw_input: Raw string input from LLM 75 | 76 | Returns: 77 | Dictionary of extracted fields 78 | """ 79 | if not isinstance(raw_input, str): 80 | return {} 81 | 82 | # First attempt: try the standard JSON sanitizer 83 | payload = sanitize_json(raw_input) 84 | if payload: 85 | return payload 86 | 87 | # Second attempt: special handling for backtick-delimited code 88 | if '`code`' in raw_input or '"code"' in raw_input: 89 | # Find the code section 90 | code_match = re.search(r'[`"]code[`"]\s*:\s*[`"](.*?)[`"](?=\s*,|\s*\})', raw_input, re.DOTALL) 91 | instance_guid_match = re.search(r'[`"]instance_guid[`"]\s*:\s*[`"](.*?)[`"]', raw_input) 92 | message_match = re.search(r'[`"]message_to_user[`"]\s*:\s*[`"](.*?)[`"]', raw_input) 93 | 94 | result = {} 95 | 96 | if instance_guid_match: 97 | result["instance_guid"] = instance_guid_match.group(1) 98 | 99 | if code_match: 100 | result["code"] = code_match.group(1) 101 | 102 | if message_match: 103 | result["message_to_user"] = message_match.group(1) 104 | 105 | return result 106 | 107 | return {} 108 | 109 | # Update the sanitize_json function to use the preprocessor 110 | def sanitize_json(json_str_or_dict: Union[str, Dict]) -> Dict[str, Any]: 111 | """ 112 | Sanitize and validate JSON input, which might come from an LLM. 113 | 114 | Args: 115 | json_str_or_dict: Either a JSON string or dictionary that might need sanitizing 116 | 117 | Returns: 118 | A properly formatted dictionary 119 | """ 120 | # If it's already a dictionary, return it 121 | if isinstance(json_str_or_dict, dict): 122 | return json_str_or_dict.copy() 123 | 124 | # If it's a string, try to fix common issues 125 | if isinstance(json_str_or_dict, str): 126 | # Apply preprocessing for LLM input 127 | json_str = preprocess_llm_input(json_str_or_dict) 128 | 129 | # Remove markdown JSON code block markers if present 130 | json_str = re.sub(r'^```json\s*', '', json_str) 131 | json_str = re.sub(r'\s*```$', '', json_str) 132 | 133 | # Try to parse the JSON 134 | try: 135 | return json.loads(json_str) 136 | except json.JSONDecodeError as e: 137 | logger.error(f"Failed to parse JSON after preprocessing: {e}") 138 | logger.error(f"Preprocessed JSON string: {json_str}") 139 | 140 | # Try another approach - remove all newlines from outside code sections 141 | try: 142 | # Find code sections 143 | if '"code"' in json_str: 144 | parts = [] 145 | last_end = 0 146 | 147 | # Find all code sections 148 | for match in re.finditer(r'"code"\s*:\s*"(.*?)"(?=\s*,|\s*\})', json_str, re.DOTALL): 149 | # Add the part before code with newlines removed 150 | before_code = json_str[last_end:match.start()] 151 | before_code = re.sub(r'\s+', ' ', before_code) 152 | parts.append(before_code) 153 | 154 | # Add the code section as is 155 | code_section = match.group(0) 156 | parts.append(code_section) 157 | 158 | last_end = match.end() 159 | 160 | # Add the remaining part 161 | remaining = json_str[last_end:] 162 | remaining = re.sub(r'\s+', ' ', remaining) 163 | parts.append(remaining) 164 | 165 | # Combine all parts 166 | json_str = ''.join(parts) 167 | 168 | return json.loads(json_str) 169 | except json.JSONDecodeError: 170 | logger.error(f"Failed to parse JSON with alternative method") 171 | 172 | # Return empty dict as fallback 173 | return {} 174 | 175 | # If it's neither a dict nor string, return empty dict 176 | return {} 177 | 178 | class GrasshopperConnection: 179 | def __init__(self, host='localhost', port=9999): # Using port 9999 to match gh_socket_server.py 180 | self.host = host 181 | self.port = port 182 | self.base_url = f"http://{host}:{port}" 183 | self.timeout = 30.0 # 30 second timeout 184 | 185 | def check_server_available(self) -> bool: 186 | """Check if the Grasshopper server is running and available. 187 | 188 | Returns: 189 | bool: True if the server is available, False otherwise 190 | """ 191 | try: 192 | response = requests.get(self.base_url, timeout=2.0) 193 | response.raise_for_status() 194 | logger.info("Grasshopper server is available at {0}".format(self.base_url)) 195 | return True 196 | except Exception as e: 197 | logger.warning("Grasshopper server is not available: {0}".format(str(e))) 198 | return False 199 | 200 | def connect(self): 201 | """Connect to the Grasshopper script's HTTP server""" 202 | # Check if server is available 203 | if not self.check_server_available(): 204 | raise Exception("Grasshopper server not available at {0}. Make sure the GHPython component is running and the toggle is set to True.".format(self.base_url)) 205 | logger.info("Connected to Grasshopper server") 206 | 207 | def disconnect(self): 208 | """No need to disconnect for HTTP connections""" 209 | pass 210 | 211 | def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]: 212 | """Send a command to the Grasshopper script and wait for response""" 213 | try: 214 | data = { 215 | "type": command_type, 216 | **(params or {}) 217 | } 218 | 219 | logger.info(f"Sending command to Grasshopper server: type={command_type}") 220 | 221 | # Use a session to handle connection properly 222 | with requests.Session() as session: 223 | response = session.post( 224 | self.base_url, 225 | json=data, 226 | timeout=self.timeout, 227 | headers={'Content-Type': 'application/json'}, 228 | stream=True 229 | ) 230 | response.raise_for_status() 231 | 232 | # Read the response content and return it directly 233 | return response.json() 234 | 235 | except requests.exceptions.RequestException as req_err: 236 | error_content = "" 237 | if hasattr(req_err, 'response') and req_err.response is not None: 238 | try: 239 | error_content = req_err.response.text 240 | except: 241 | pass 242 | 243 | error_msg = f"HTTP request error: {str(req_err)}. Response: {error_content}" 244 | logger.error(error_msg) 245 | return {"status": "error", "result": error_msg} 246 | 247 | except Exception as e: 248 | error_msg = f"Error communicating with Grasshopper script: {str(e)}" 249 | logger.error(error_msg) 250 | return {"status": "error", "result": error_msg} 251 | 252 | # Global connection instance 253 | _grasshopper_connection = None 254 | 255 | def get_grasshopper_connection() -> GrasshopperConnection: 256 | """Get or create the Grasshopper connection""" 257 | global _grasshopper_connection 258 | if _grasshopper_connection is None: 259 | _grasshopper_connection = GrasshopperConnection() 260 | return _grasshopper_connection 261 | 262 | class GrasshopperTools: 263 | """Collection of tools for interacting with Grasshopper.""" 264 | 265 | def __init__(self, app): 266 | self.app = app 267 | self._register_tools() 268 | 269 | def _register_tools(self): 270 | """Register all Grasshopper tools with the MCP server.""" 271 | self.app.tool()(self.is_server_available) 272 | self.app.tool()(self.execute_code_in_gh) 273 | self.app.tool()(self.get_gh_context) 274 | self.app.tool()(self.get_objects) 275 | self.app.tool()(self.get_selected) 276 | self.app.tool()(self.update_script) 277 | self.app.tool()(self.update_script_with_code_reference) 278 | self.app.tool()(self.expire_and_get_info) 279 | 280 | def is_server_available(self, ctx: Context) -> bool: 281 | """Grasshopper: Check if the Grasshopper server is available. 282 | 283 | This is a quick check to see if the Grasshopper socket server is running 284 | and available for connections. 285 | 286 | Returns: 287 | bool: True if the server is available, False otherwise 288 | """ 289 | try: 290 | connection = get_grasshopper_connection() 291 | return connection.check_server_available() 292 | except Exception as e: 293 | logger.error("Error checking Grasshopper server availability: {0}".format(str(e))) 294 | return False 295 | 296 | def execute_code_in_gh(self, ctx: Context, code: str) -> str: 297 | """Grasshopper: Execute arbitrary Python code in Grasshopper. 298 | 299 | IMPORTANT: 300 | - Uses IronPython 2.7 - no f-strings or modern Python features 301 | - Always include ALL required imports in your code 302 | - Use 'result = value' to return data (don't use return statements) 303 | 304 | Example - Adding components to canvas: 305 | ```python 306 | import scriptcontext as sc 307 | import clr 308 | import Rhino 309 | import System.Drawing as sd 310 | import Grasshopper 311 | import Grasshopper.Kernel.Special as GHSpecial 312 | 313 | doc = ghenv.Component.OnPingDocument() 314 | 315 | # Create and position a Pipeline 316 | pipe = GHSpecial.GH_GeometryPipeline() 317 | if pipe.Attributes is None: pipe.CreateAttributes() 318 | pipe.Attributes.Pivot = sd.PointF(100, 100) 319 | doc.AddObject(pipe, False) 320 | 321 | # Create and connect a Panel 322 | pan = GHSpecial.GH_Panel() 323 | if pan.Attributes is None: pan.CreateAttributes() 324 | pan.Attributes.Pivot = sd.PointF(300, 100) 325 | doc.AddObject(pan, False) 326 | pan.AddSource(pipe) 327 | 328 | result = "Components created successfully" 329 | ``` 330 | 331 | You can also provide the code as part of a JSON object with a "code" field. 332 | 333 | Args: 334 | code: The Python code to execute, or a JSON object with a "code" field 335 | 336 | Returns: 337 | The result of the code execution 338 | """ 339 | try: 340 | # Check if the input might be a JSON payload 341 | if isinstance(code, str) and ( 342 | code.strip().startswith('{') or 343 | code.strip().startswith('`{') or 344 | '`code`' in code or 345 | '"code"' in code 346 | ): 347 | # Try direct extraction for speed and reliability 348 | payload = extract_payload_fields(code) 349 | if payload and "code" in payload: 350 | code = payload["code"] 351 | 352 | # Validate that we have code to execute 353 | if not code or not isinstance(code, str): 354 | return "Error: No valid code provided. Please provide Python code to execute." 355 | 356 | # Make sure code ends with a result variable if it doesn't have one 357 | if "result =" not in code and "result=" not in code: 358 | # Extract the last line if it starts with "return" 359 | lines = code.strip().split("\n") 360 | if lines and lines[-1].strip().startswith("return "): 361 | return_value = lines[-1].strip()[7:].strip() # Remove "return " prefix 362 | # Replace the return with a result assignment 363 | lines[-1] = "result = " + return_value 364 | code = "\n".join(lines) 365 | else: 366 | # Append a default result if no return or result is present 367 | code += "\n\n# Auto-added result assignment\nresult = \"Code executed successfully\"" 368 | 369 | logger.info(f"Sending code execution request to Grasshopper") 370 | connection = get_grasshopper_connection() 371 | 372 | result = connection.send_command("execute_code", { 373 | "code": code 374 | }) 375 | 376 | # Simply return result info with error prefix if needed 377 | if result.get("status") == "error": 378 | return f"Error: {result.get('result', 'Unknown error')}" 379 | return result.get("result", "Code executed successfully") 380 | 381 | except Exception as e: 382 | return f"Error executing code: {str(e)}" 383 | 384 | def get_gh_context(self, ctx: Context, simplified: bool = False) -> str: 385 | """Grasshopper: Get current Grasshopper document state and definition graph, sorted by execution order. 386 | 387 | Returns a JSON string containing: 388 | - Component graph (connections between components) 389 | - Component info (guid, name, type) 390 | - Component properties and parameters 391 | 392 | Args: 393 | simplified: When true, returns minimal component info without detailed properties 394 | 395 | Returns: 396 | JSON string with grasshopper definition graph 397 | """ 398 | try: 399 | logger.info("Getting Grasshopper context with simplified={0}".format(simplified)) 400 | connection = get_grasshopper_connection() 401 | result = connection.send_command("get_context", { 402 | "simplified": simplified 403 | }) 404 | 405 | if result.get("status") == "error": 406 | return f"Error: {result.get('result', 'Unknown error')}" 407 | return json.dumps(result.get("result", {}), indent=2) 408 | 409 | except Exception as e: 410 | return f"Error getting context: {str(e)}" 411 | 412 | def get_objects(self, ctx: Context, instance_guids: List[str], simplified: bool = False, context_depth: int = 0) -> str: 413 | """Grasshopper: Get information about specific components by their GUIDs. 414 | 415 | Args: 416 | instance_guids: List of component GUIDs to retrieve 417 | simplified: When true, returns minimal component info 418 | context_depth: How many levels of connected components to include (0-3), try to keep it small 419 | 420 | Returns: 421 | JSON string with component information and optional context 422 | """ 423 | try: 424 | logger.info("Getting objects with GUIDs: {0}".format(instance_guids)) 425 | connection = get_grasshopper_connection() 426 | result = connection.send_command("get_objects", { 427 | "instance_guids": instance_guids, 428 | "simplified": simplified, 429 | "context_depth": context_depth 430 | }) 431 | 432 | if result.get("status") == "error": 433 | return f"Error: {result.get('result', 'Unknown error')}" 434 | return json.dumps(result.get("result", {}), indent=2) 435 | 436 | except Exception as e: 437 | return f"Error getting objects: {str(e)}" 438 | 439 | def get_selected(self, ctx: Context, simplified: bool = False, context_depth: int = 0) -> str: 440 | """Grasshopper: Get information about currently selected components. 441 | 442 | Args: 443 | simplified: When true, returns minimal component info 444 | context_depth: How many levels of connected components to include (0-3) 445 | 446 | Returns: 447 | JSON string with selected component information and optional context 448 | """ 449 | try: 450 | logger.info("Getting selected components") 451 | connection = get_grasshopper_connection() 452 | result = connection.send_command("get_selected", { 453 | "simplified": simplified, 454 | "context_depth": context_depth 455 | }) 456 | 457 | if result.get("status") == "error": 458 | return f"Error: {result.get('result', 'Unknown error')}" 459 | return json.dumps(result.get("result", {}), indent=2) 460 | 461 | except Exception as e: 462 | return f"Error getting selected components: {str(e)}" 463 | 464 | def update_script(self, ctx: Context, instance_guid: str = None, code: str = None, description: str = None, 465 | message_to_user: str = None, param_definitions: List[Dict[str, Any]] = None) -> str: 466 | """Grasshopper: Update a script component with new code, description, user feedback message, and optionally redefine its parameters. 467 | 468 | IMPORTANT NOTES: 469 | 0. the output param "output" is reserved for the "message_to_user", name output params with a meaningful name if you create new ones 470 | 1. The code must be valid Python 2.7 / IronPython code (no f-strings!) 471 | 2. When updating existing code: 472 | - If NOT changing parameters, ensure to keep the same input/output variable names! 473 | - Know their datatypes and access methods (list, datatree, item) before modifying 474 | - The script may be part of a larger definition - maintain input/output structure 475 | 3. When changing input and outputparameters: 476 | - You must provide ALL input/output parameters, even existing ones you want to keep 477 | - The component will be completely reconfigured with the new parameter set 478 | - Existing connections may be lost if parameter names change 479 | 480 | Example: 481 | ```json 482 | { 483 | "instance_guid": "a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a", 484 | "code": "import Rhino.Geometry as rg\\n\\n# Create circle from radius\\norigin = rg.Point3d(0, 0, 0)\\ncircle = rg.Circle(origin, radius)\\n\\n# Set outputs\\nresult = circle\\ncircle_center = circle.Center\\ncircle_area = circle.Area", 485 | "description": "Creates a circle and outputs its geometry, center point, and area", 486 | "message_to_user": "Circle component updated with new outputs for center point and area calculation", 487 | "param_definitions": [ 488 | { 489 | "type": "input", 490 | "name": "radius", 491 | "access": "item", 492 | "typehint": "float", 493 | "description": "Circle radius", 494 | "optional": false, 495 | "default": 1.0 496 | }, 497 | { 498 | "type": "output", 499 | "name": "circle", 500 | "description": "Generated circle geometry" 501 | }, 502 | { 503 | "type": "output", 504 | "name": "center", 505 | "description": "Center point of the circle" 506 | }, 507 | { 508 | "type": "output", 509 | "name": "output", 510 | "description": "Used to display messages to the user" 511 | } 512 | ] 513 | } 514 | ``` 515 | 516 | Args: 517 | instance_guid: The GUID of the script component to update 518 | code: Optional new Python code for the component 519 | description: Optional new description for the component 520 | message_to_user: Optional feedback message that should include a change summary and/or suggestions 521 | param_definitions: Optional list of parameter definitions. If provided, ALL parameters will be redefined. 522 | Each definition must be a dictionary with: 523 | Required keys: 524 | - "type": "input" or "output" 525 | - "name": Parameter name (string) 526 | Optional keys for inputs: 527 | - "access": "item", "list", or "tree" (default "list") 528 | - "typehint": e.g. "str", "int", "float", "bool" (determines parameter type) 529 | - "description": Parameter description 530 | - "optional": bool, default True 531 | - "default": Default value (not persistent) 532 | 533 | Returns: 534 | Success status with summary of which elements were updated 535 | """ 536 | try: 537 | # Log initial input for debugging 538 | if isinstance(instance_guid, str) and len(instance_guid) > 200: 539 | logger.info(f"Received long payload as instance_guid parameter: first 100 chars: {instance_guid[:100]}...") 540 | else: 541 | logger.info(f"Initial parameters: instance_guid={instance_guid}, code length={len(code) if code else 0}, " 542 | f"description={'provided' if description else 'None'}, " 543 | f"message_to_user={'provided' if message_to_user else 'None'}, " 544 | f"param_definitions={'provided' if param_definitions else 'None'}") 545 | 546 | # Check if the first argument is a string that looks like a JSON payload 547 | if isinstance(instance_guid, str) and ( 548 | instance_guid.strip().startswith('{') or 549 | instance_guid.strip().startswith('`{') or 550 | '`instance_guid`' in instance_guid or 551 | '"instance_guid"' in instance_guid 552 | ): 553 | logger.info("Detected JSON-like payload in instance_guid parameter, extracting fields") 554 | # More robust extraction for complex payloads 555 | payload = extract_payload_fields(instance_guid) 556 | if payload and "instance_guid" in payload: 557 | # Log what was extracted 558 | logger.info(f"Extracted fields from payload: {sorted(payload.keys())}") 559 | 560 | instance_guid = payload.get("instance_guid") 561 | code = payload.get("code", code) 562 | description = payload.get("description", description) 563 | message_to_user = payload.get("message_to_user", message_to_user) 564 | param_definitions = payload.get("param_definitions", param_definitions) 565 | 566 | logger.info(f"After extraction: instance_guid={instance_guid}, code length={len(code) if code else 0}") 567 | else: 568 | logger.warning("Failed to extract instance_guid from payload") 569 | 570 | # Ensure we have a valid instance_guid 571 | if not instance_guid: 572 | logger.error("No instance_guid provided") 573 | return "Error: No instance_guid provided. Please specify the GUID of the script component to update." 574 | 575 | logger.info(f"Updating script component {instance_guid}") 576 | logger.info(f"Parameter details: code={bool(code)}, description={bool(description)}, " 577 | f"message_to_user={bool(message_to_user)}, param_definitions type={type(param_definitions) if param_definitions else None}") 578 | 579 | connection = get_grasshopper_connection() 580 | 581 | # Sanitize param_definitions if provided 582 | if param_definitions is not None and isinstance(param_definitions, list): 583 | # Create new sanitized list 584 | sanitized_params = [] 585 | for param in param_definitions: 586 | if isinstance(param, dict): 587 | sanitized_params.append(param.copy()) 588 | else: 589 | # Try to parse if it's a string 590 | try: 591 | if isinstance(param, str): 592 | param_dict = json.loads(preprocess_llm_input(param)) 593 | sanitized_params.append(param_dict) 594 | except: 595 | logger.warning(f"Could not parse parameter definition: {param}") 596 | 597 | param_definitions = sanitized_params 598 | 599 | # Prepare the command payload - log it before sending 600 | command_payload = { 601 | "instance_guid": instance_guid, 602 | "code": code, 603 | "description": description, 604 | "message_to_user": message_to_user, 605 | "param_definitions": param_definitions 606 | } 607 | 608 | logger.info(f"Sending command with payload keys: {sorted(command_payload.keys())}") 609 | if code: 610 | logger.info(f"Code snippet (first 50 chars): {code[:50]}...") 611 | 612 | # Always use "update_script" as the command type 613 | result = connection.send_command("update_script", command_payload) 614 | 615 | if result.get("status") == "error": 616 | return f"Error: {result.get('result', 'Unknown error')}" 617 | return json.dumps(result.get("result", {}), indent=2) 618 | 619 | except Exception as e: 620 | return f"Error updating script: {str(e)}" 621 | 622 | def update_script_with_code_reference(self, ctx: Context, instance_guid: str = None, file_path: str = None, 623 | param_definitions: List[Dict[str, Any]] = None, description: str = None, 624 | name: str = None, force_code_reference: bool = False) -> str: 625 | """Grasshopper: Update a script component to use code from an external Python file. 626 | This tool allows you to modify a GHPython script component to use code from an external Python file 627 | instead of embedded code. This enables better code organization, version control, and reuse across 628 | multiple components. Moreove, you can add and remove input/ output paramters. 629 | 630 | important notes: 631 | 1. Only use when working in/with curser or another IDE 632 | 2. First, check the grasshopper script component using "get_objects" tool 633 | 3. Second, check if a python file is already referenced by the component AND if it exists in the cursor project 634 | ALWAYS add the component instance_guid to the file name (e.g. cirler_creator_a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a.py) 635 | 4. write code im the file and save it, update the file path with this tool 636 | 5. Once referenced, future updates on the code file will automatically be reflected in the component (no need to use this tool) 637 | 6. you can use get_objects tool to get potential error messages from the component for debugging (runtimeMessages) 638 | 639 | Args: 640 | instance_guid: The GUID of the target GHPython component to modify. 641 | file_path: Path to the external Python file that contains the code. 642 | param_definitions: List of dictionaries defining input/output parameters. 643 | description: New description for the component. 644 | name: New nickname for the component. 645 | force_code_reference: When True, converts/sets a component to use referenced code mode. 646 | 647 | Returns: 648 | Success status with summary of which elements were updated and component instance_guid 649 | 650 | Example: 651 | ```json 652 | { 653 | "instance_guid": "a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a", 654 | "file_path": "/scripts/cirler_creator_a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a.py" 655 | "name":"CircleTool" 656 | "description": "Creates a circle and outputs its geometry, center point, and area", 657 | "message_to_user": "Circle, add one radius slider as input", 658 | force_code_reference = True, 659 | "param_definitions": [ 660 | { 661 | "type": "input", 662 | "name": "radius", 663 | "access": "item", 664 | "typehint": "float", 665 | "description": "Circle radius", 666 | "optional": false, 667 | "default": 1.0 668 | }, 669 | { 670 | "type": "output", 671 | "name": "circle", 672 | "description": "Generated circle geometry" 673 | } 674 | ] 675 | } 676 | ``` 677 | """ 678 | try: 679 | # Ensure we have a valid instance_guid 680 | if not instance_guid: 681 | return "Error: No instance_guid provided. Please specify the GUID of the script component to update." 682 | 683 | connection = get_grasshopper_connection() 684 | 685 | # Prepare the command payload 686 | command_payload = { 687 | "instance_guid": instance_guid, 688 | "file_path": file_path, 689 | "param_definitions": param_definitions, 690 | "description": description, 691 | "name": name, 692 | "force_code_reference": force_code_reference 693 | } 694 | 695 | # Send command and get result 696 | result = connection.send_command("update_script_with_code_reference", command_payload) 697 | 698 | if result.get("status") == "error": 699 | return f"Error: {result.get('result', 'Unknown error')}" 700 | return json.dumps(result.get("result", {}), indent=2) 701 | 702 | except Exception as e: 703 | return f"Error updating script with code reference: {str(e)}" 704 | 705 | def expire_and_get_info(self, ctx: Context, instance_guid: str) -> str: 706 | """Grasshopper: Expire a specific component and get its updated information. 707 | 708 | This is useful after updating a component's code, especially via a referenced file, 709 | to force a recompute and retrieve the latest state, including potential errors or messages. 710 | 711 | Args: 712 | instance_guid: The GUID of the component to expire and query. 713 | 714 | Returns: 715 | JSON string with the component's updated information after expiration. 716 | """ 717 | try: 718 | if not instance_guid: 719 | return "Error: No instance_guid provided. Please specify the GUID of the component to expire." 720 | 721 | logger.info(f"Expiring component and getting info for GUID: {instance_guid}") 722 | connection = get_grasshopper_connection() 723 | result = connection.send_command("expire_component", { 724 | "instance_guid": instance_guid 725 | }) 726 | 727 | if result.get("status") == "error": 728 | return f"Error: {result.get('result', 'Unknown error')}" 729 | # The server side already returns component info after expiring 730 | return json.dumps(result.get("result", {}), indent=2) 731 | 732 | except Exception as e: 733 | return f"Error expiring component: {str(e)}" 734 | -------------------------------------------------------------------------------- /GHCodeMCP_old_working.py: -------------------------------------------------------------------------------- 1 | import scriptcontext as sc 2 | import clr, socket, threading, Rhino, json 3 | clr.AddReference("System") 4 | clr.AddReference("System.Drawing") 5 | clr.AddReference("Grasshopper") 6 | from System import Action 7 | from System.Drawing import RectangleF 8 | import Grasshopper 9 | import Grasshopper as gh 10 | import Rhino.Geometry as rg 11 | import System 12 | from System import Guid 13 | from Grasshopper.Kernel import GH_ParamAccess 14 | from Grasshopper.Kernel.Parameters import Param_GenericObject 15 | from Grasshopper.Kernel.Parameters import Param_String, Param_Number, Param_Integer, Param_Boolean 16 | 17 | 18 | class GHEncoder(json.JSONEncoder): 19 | """Custom JSON encoder for Grasshopper/Rhino types""" 20 | def default(self, obj): 21 | if isinstance(obj, rg.Point3d): 22 | return { 23 | "x": float(obj.X), 24 | "y": float(obj.Y), 25 | "z": float(obj.Z) 26 | } 27 | elif isinstance(obj, RectangleF): 28 | return { 29 | "x": float(obj.X), 30 | "y": float(obj.Y), 31 | "width": float(obj.Width), 32 | "height": float(obj.Height) 33 | } 34 | return json.JSONEncoder.default(self, obj) 35 | 36 | # Use scriptcontext.sticky as a persistent dictionary. 37 | if "command" not in sc.sticky: 38 | sc.sticky["command"] = None 39 | if "server_running" not in sc.sticky: 40 | sc.sticky["server_running"] = False 41 | if "last_result" not in sc.sticky: 42 | sc.sticky["last_result"] = None 43 | if "server_thread" not in sc.sticky: 44 | sc.sticky["server_thread"] = None 45 | 46 | 47 | def get_param_info(param, is_input=True, parent_instance_guid=None, simplified=False, is_selected=False): 48 | """Collect information from a Grasshopper parameter.""" 49 | if simplified: 50 | # Simplified param info with just connections 51 | info = { 52 | "instanceGuid": str(param.InstanceGuid), 53 | "name": param.Name, 54 | "sources": [], 55 | "targets": [], 56 | "isSelected": is_selected 57 | } 58 | 59 | # Get sources (inputs) 60 | if hasattr(param, "Sources"): 61 | for src in param.Sources: 62 | try: 63 | info["sources"].append(str(src.InstanceGuid)) 64 | except: 65 | pass 66 | 67 | # Get targets (outputs) 68 | if hasattr(param, "Recipients"): 69 | for tgt in param.Recipients: 70 | try: 71 | info["targets"].append(str(tgt.InstanceGuid)) 72 | except: 73 | pass 74 | 75 | # If this is a component param, add its parent to targets or sources 76 | if parent_instance_guid: 77 | if is_input and str(parent_instance_guid) not in info["targets"]: 78 | info["targets"].append(str(parent_instance_guid)) 79 | elif not is_input and str(parent_instance_guid) not in info["sources"]: 80 | info["sources"].append(str(parent_instance_guid)) 81 | 82 | return info 83 | 84 | # Detailed param info 85 | try: 86 | bounds_rect = RectangleF( 87 | param.Attributes.Bounds.X, 88 | (param.Attributes.Bounds.Y * -1) - param.Attributes.Bounds.Height, 89 | param.Attributes.Bounds.Width, 90 | param.Attributes.Bounds.Height 91 | ) 92 | pivot_pt = rg.Point3d(param.Attributes.Pivot.X, param.Attributes.Pivot.Y * -1, 0) 93 | 94 | param_info = { 95 | "instanceGuid": str(param.InstanceGuid), 96 | "parentInstanceGuid": str(parent_instance_guid) if parent_instance_guid else None, 97 | "bounds": bounds_rect, 98 | "pivot": pivot_pt, 99 | "dataMapping": str(param.DataMapping) if hasattr(param, 'DataMapping') else None, 100 | "dataType": str(param.TypeName) if hasattr(param, 'TypeName') else None, 101 | "simplify": str(param.Simplify) if hasattr(param, 'Simplify') else None, 102 | "name": param.Name, 103 | "nickName": param.NickName, 104 | "category": param.Category, 105 | "subCategory": param.SubCategory, 106 | "description": param.Description, 107 | "kind": str(param.Kind) if hasattr(param, 'Kind') else None, 108 | "sources": [], 109 | "targets": [], 110 | "isSelected": is_selected 111 | } 112 | 113 | # Add specific input parameter properties 114 | if is_input: 115 | try: 116 | param_info["InputAccess"] = str(param.Access) 117 | except Exception as e: 118 | param_info["InputAccess"] = "N/A" 119 | try: 120 | param_info["dataTypeHint"] = str(param.TypeHint) 121 | except Exception as e: 122 | param_info["dataTypeHint"] = "N/A" 123 | 124 | # Get sources (inputs) 125 | for src in param.Sources: 126 | try: 127 | param_info["sources"].append(str(src.InstanceGuid)) 128 | except: 129 | pass 130 | 131 | # Get targets (outputs) 132 | for tgt in param.Recipients: 133 | try: 134 | param_info["targets"].append(str(tgt.InstanceGuid)) 135 | except: 136 | pass 137 | 138 | # If this is a component param, add its parent to targets or sources 139 | if parent_instance_guid: 140 | if is_input and str(parent_instance_guid) not in param_info["targets"]: 141 | param_info["targets"].append(str(parent_instance_guid)) 142 | elif not is_input and str(parent_instance_guid) not in param_info["sources"]: 143 | param_info["sources"].append(str(parent_instance_guid)) 144 | 145 | return param_info 146 | except Exception as e: 147 | print("Error getting param info: " + str(e)) 148 | return None 149 | 150 | def get_component_info(comp, simplified=False, is_selected=False): 151 | """Collect information from a Grasshopper component.""" 152 | if simplified: 153 | # Simplified component info 154 | info = { 155 | "instanceGuid": str(comp.InstanceGuid), 156 | "name": comp.Name, 157 | "nickName": comp.NickName, 158 | "description": comp.Description, 159 | "pivot": {"X": float(comp.Attributes.Pivot.X), "Y": float(comp.Attributes.Pivot.Y)} if (hasattr(comp, "Attributes") and comp.Attributes) else {}, 160 | "inputs": [], 161 | "outputs": [], 162 | "sources": [], 163 | "targets": [], 164 | "isSelected": is_selected 165 | 166 | } 167 | 168 | try: 169 | info["runTimeMessage"]= str(component.RuntimeMessages(component.RuntimeMessageLevel)).replace("List[str]","") 170 | except: 171 | pass 172 | # Get input and output parameter info 173 | if hasattr(comp, "Params"): 174 | if hasattr(comp.Params, "Input"): 175 | info["inputs"] = [get_param_info(p, is_input=True, parent_instance_guid=comp.InstanceGuid, simplified=True) for p in comp.Params.Input] 176 | if hasattr(comp.Params, "Output"): 177 | info["outputs"] = [get_param_info(p, is_input=False, parent_instance_guid=comp.InstanceGuid, simplified=True) for p in comp.Params.Output] 178 | 179 | return info 180 | 181 | # Get component kind with fallback 182 | try: 183 | kind = str(comp.Kind) if hasattr(comp, 'Kind') else str(comp.__class__.__name__) 184 | except: 185 | kind = str(comp.__class__.__name__) 186 | 187 | # Basic info for all components 188 | comp_info = { 189 | "instanceGuid": str(comp.InstanceGuid), 190 | "name": comp.Name, 191 | "nickName": comp.NickName, 192 | "description": comp.Description, 193 | "category": comp.Category, 194 | "subCategory": comp.SubCategory, 195 | "kind": kind, 196 | "sources": [], 197 | "targets": [], 198 | "isSelected": is_selected 199 | } 200 | 201 | try: 202 | comp_info["runTimeMessage"]= str(component.RuntimeMessages(component.RuntimeMessageLevel)).replace("List[str]","") 203 | except: 204 | pass 205 | # Add additional info for non-standard components 206 | if kind != "component": 207 | comp_info.update({ 208 | "bounds": RectangleF( 209 | comp.Attributes.Bounds.X, 210 | (comp.Attributes.Bounds.Y * -1) - comp.Attributes.Bounds.Height, 211 | comp.Attributes.Bounds.Width, 212 | comp.Attributes.Bounds.Height 213 | ), 214 | "pivot": rg.Point3d(comp.Attributes.Pivot.X, comp.Attributes.Pivot.Y * -1, 0), 215 | "dataMapping": None, 216 | "dataType": None, 217 | "simplify": None, 218 | "computiationTime": float(comp.ProcessorTime.Milliseconds), 219 | "dataCount": None, 220 | "pathCount": None 221 | }) 222 | 223 | # If the component is a script component, add its code. 224 | if comp.SubCategory == "Script": 225 | if hasattr(comp, "Code"): 226 | comp_info["Code"] = comp.Code 227 | #checks if code 228 | comp_info["codeReferenceFromFile"] = comp.InputIsPath 229 | # if so check if we can get the file path 230 | if comp.InputIsPath: 231 | try: 232 | for p in comp.Params.Input: 233 | if p.Name == "code" and p.VolatileDataCount > 0: 234 | comp_info["codeReferencePath"] = str(p.VolatileData.get_DataItem(0)) 235 | break 236 | except: 237 | pass 238 | else: 239 | comp_info["Code"] = "none" 240 | 241 | # Get input and output parameter info if available. 242 | if hasattr(comp, "Params"): 243 | if hasattr(comp.Params, "Input"): 244 | comp_info["Inputs"] = [get_param_info(p, is_input=True, parent_instance_guid=comp.InstanceGuid) for p in comp.Params.Input] 245 | if hasattr(comp.Params, "Output"): 246 | comp_info["Outputs"] = [get_param_info(p, is_input=False, parent_instance_guid=comp.InstanceGuid) for p in comp.Params.Output] 247 | 248 | return comp_info 249 | 250 | def get_standalone_param_info(param, simplified=False, is_selected=False): 251 | """Collect information for standalone parameters (sliders, panels, etc.)""" 252 | return get_param_info(param, is_input=False, simplified=simplified, is_selected=is_selected) 253 | 254 | def sort_graph_by_execution_order(graph): 255 | """ 256 | Sort the graph dictionary by component execution order. 257 | 258 | Args: 259 | graph: The graph dictionary containing component and parameter information 260 | 261 | Returns: 262 | A new graph dictionary with keys ordered by execution sequence 263 | """ 264 | # Create a dictionary to store in-degrees (number of incoming edges) 265 | in_degree = {node_id: 0 for node_id in graph} 266 | 267 | # Calculate in-degree for each node 268 | for node_id, node_info in graph.items(): 269 | if "targets" in node_info: 270 | for target_id in node_info["targets"]: 271 | if target_id in in_degree: 272 | in_degree[target_id] += 1 273 | 274 | # Queue with nodes that have no incoming edges (in-degree = 0) 275 | queue = [node_id for node_id, degree in in_degree.items() if degree == 0] 276 | 277 | # List to store the sorted order 278 | sorted_order = [] 279 | 280 | # Process nodes in the queue 281 | while queue: 282 | # Get the next node 283 | current_id = queue.pop(0) 284 | sorted_order.append(current_id) 285 | 286 | # Reduce in-degree of all targets (downstream components) 287 | if "targets" in graph[current_id]: 288 | for target_id in graph[current_id]["targets"]: 289 | if target_id in in_degree: 290 | in_degree[target_id] -= 1 291 | # If in-degree becomes 0, add to queue 292 | if in_degree[target_id] == 0: 293 | queue.append(target_id) 294 | 295 | # For any remaining nodes (cycles or unreachable), add to the end 296 | remaining_nodes = [node_id for node_id in graph if node_id not in sorted_order] 297 | sorted_order.extend(remaining_nodes) 298 | 299 | # Create a new ordered dictionary 300 | ordered_graph = {} 301 | for node_id in sorted_order: 302 | if node_id in graph: 303 | ordered_graph[node_id] = graph[node_id] 304 | 305 | return ordered_graph 306 | 307 | def get_objects(instance_guids, context_depth=0, simplified=False): 308 | """ 309 | Get Grasshopper objects by their instance GUIDs with optional context. 310 | Focuses on retrieving components and standalone parameters. 311 | Returns a dictionary keyed by instance GUID, sorted by execution order. 312 | (Simplified revision to address complexity issues) 313 | """ 314 | doc = ghenv.Component.OnPingDocument() 315 | if not doc: 316 | sc.sticky["processing_error"] = "get_objects: No active Grasshopper document." 317 | return {} 318 | 319 | if not isinstance(instance_guids, list): 320 | instance_guids = [str(instance_guids)] 321 | else: 322 | instance_guids = [str(g) for g in instance_guids] 323 | 324 | initial_target_guids = set(instance_guids) 325 | result_graph = {} # Stores info for components and standalone params 326 | guids_added_to_result = set() # Track what's already in the result 327 | 328 | # --- Build a map of all potential objects and their basic types/parents --- 329 | object_map = {} # {guid_str: {'obj': obj, 'is_comp': bool, 'is_param': bool, 'parent_guid': str|None}} 330 | for obj in doc.Objects: 331 | if not hasattr(obj, "InstanceGuid"): continue 332 | guid_str = str(obj.InstanceGuid) 333 | parent_guid = None 334 | parent_comp = obj.Attributes.Parent if hasattr(obj, "Attributes") and obj.Attributes else None 335 | if parent_comp and hasattr(parent_comp, "InstanceGuid"): 336 | parent_guid = str(parent_comp.InstanceGuid) 337 | 338 | object_map[guid_str] = { 339 | 'obj': obj, 340 | 'is_comp': isinstance(obj, Grasshopper.Kernel.IGH_Component), 341 | 'is_param': isinstance(obj, Grasshopper.Kernel.IGH_Param), 342 | 'parent_guid': parent_guid 343 | } 344 | 345 | # --- Pass 1: Process initially requested GUIDs --- 346 | guids_to_process_for_context = set() 347 | for target_guid_str in initial_target_guids: 348 | if target_guid_str in object_map: 349 | item = object_map[target_guid_str] 350 | obj_to_add = None 351 | obj_guid_to_add = None 352 | is_selected_flag = True # This object was directly requested 353 | 354 | if item['is_comp']: 355 | # Selected object is a component 356 | obj_to_add = item['obj'] 357 | obj_guid_to_add = target_guid_str 358 | elif item['is_param']: 359 | if item['parent_guid']: 360 | # Selected object is a component parameter - add the parent component instead 361 | parent_guid_str = item['parent_guid'] 362 | if parent_guid_str in object_map: 363 | obj_to_add = object_map[parent_guid_str]['obj'] 364 | obj_guid_to_add = parent_guid_str 365 | # Mark the parent as selected since its child was selected? 366 | # Let's keep the component info function handling isSelected for the component itself. 367 | # We just need to ensure the parent component is included. 368 | else: 369 | # Parent component not found? Log error maybe. 370 | sc.sticky["processing_error"] = sc.sticky.get("processing_error", "") + \ 371 | "\nWarning: Parent component {} not found for selected param {}.".format(item['parent_guid'], target_guid_str) 372 | else: 373 | # Selected object is a standalone parameter 374 | obj_to_add = item['obj'] 375 | obj_guid_to_add = target_guid_str 376 | # else: Handle other selected types if needed? For now, focus on comp/param. 377 | 378 | # If we identified a component or standalone param to add, get its info 379 | if obj_to_add and obj_guid_to_add and obj_guid_to_add not in guids_added_to_result: 380 | info = None 381 | if isinstance(obj_to_add, Grasshopper.Kernel.IGH_Component): 382 | info = get_component_info(obj_to_add, simplified=simplified, is_selected=(obj_guid_to_add in initial_target_guids)) 383 | elif isinstance(obj_to_add, Grasshopper.Kernel.IGH_Param): # Standalone only branch 384 | info = get_standalone_param_info(obj_to_add, simplified=simplified, is_selected=(obj_guid_to_add in initial_target_guids)) 385 | 386 | if info: 387 | result_graph[obj_guid_to_add] = info 388 | guids_added_to_result.add(obj_guid_to_add) 389 | guids_to_process_for_context.add(obj_guid_to_add) # Use this GUID for context traversal 390 | 391 | 392 | # --- Pass 2: Traverse Context --- 393 | if context_depth > 0 and guids_to_process_for_context: 394 | # Build simplified link graph *only* for components and standalone params needed for traversal 395 | link_graph = {} # {guid: {"sources": [...], "targets": [...]}} 396 | for guid_str, item in object_map.items(): 397 | node_sources = [] 398 | node_targets = [] 399 | current_obj = item['obj'] 400 | 401 | if item['is_comp']: 402 | # Component: sources from inputs, targets from outputs 403 | if hasattr(current_obj.Params, "Input"): 404 | for p in current_obj.Params.Input: 405 | if hasattr(p, "Sources"): 406 | node_sources.extend([str(src.InstanceGuid) for src in p.Sources if src and hasattr(src,"InstanceGuid")]) 407 | if hasattr(current_obj.Params, "Output"): 408 | for p in current_obj.Params.Output: 409 | if hasattr(p, "Recipients"): 410 | # Target is the recipient param itself, or its parent component if it has one 411 | for recipient in p.Recipients: 412 | if recipient and hasattr(recipient, "InstanceGuid"): 413 | rec_guid = str(recipient.InstanceGuid) 414 | rec_parent = recipient.Attributes.Parent if hasattr(recipient, "Attributes") and recipient.Attributes else None 415 | if rec_parent and hasattr(rec_parent,"InstanceGuid"): 416 | node_targets.append(str(rec_parent.InstanceGuid)) # Target the component 417 | else: 418 | node_targets.append(rec_guid) # Target the standalone param 419 | 420 | elif item['is_param'] and not item['parent_guid']: # Standalone parameter 421 | # Standalone Param: sources from its sources, targets are its recipients 422 | if hasattr(current_obj, "Sources"): 423 | node_sources.extend([str(src.InstanceGuid) for src in current_obj.Sources if src and hasattr(src,"InstanceGuid")]) 424 | if hasattr(current_obj, "Recipients"): 425 | # Target is the recipient param itself, or its parent component if it has one 426 | for recipient in current_obj.Recipients: 427 | if recipient and hasattr(recipient, "InstanceGuid"): 428 | rec_guid = str(recipient.InstanceGuid) 429 | rec_parent = recipient.Attributes.Parent if hasattr(recipient, "Attributes") and recipient.Attributes else None 430 | if rec_parent and hasattr(rec_parent,"InstanceGuid"): 431 | node_targets.append(str(rec_parent.InstanceGuid)) # Target the component 432 | else: 433 | node_targets.append(rec_guid) # Target the standalone param 434 | 435 | if node_sources or node_targets: 436 | # Only add nodes that actually have connections relevant to components/standalone params 437 | link_graph[guid_str] = {"sources": list(set(node_sources)), "targets": list(set(node_targets))} 438 | 439 | # --- Perform Traversal --- 440 | context_guids_to_add = set() 441 | visited_for_context = set(guids_to_process_for_context) # Start with the identified core items 442 | current_level = set(guids_to_process_for_context) 443 | max_depth = min(int(context_depth), 3) 444 | 445 | for _ in range(max_depth): 446 | next_level = set() 447 | for guid in current_level: 448 | if guid in link_graph: 449 | node_links = link_graph[guid] 450 | # Add upstream (sources) 451 | for src in node_links.get("sources", []): 452 | if src in object_map and src not in visited_for_context: # Check if src is a valid obj in our map 453 | # Only add components or standalone params as context 454 | if object_map[src]['is_comp'] or (object_map[src]['is_param'] and not object_map[src]['parent_guid']): 455 | next_level.add(src) 456 | visited_for_context.add(src) 457 | context_guids_to_add.add(src) 458 | # Add downstream (targets) 459 | for tgt in node_links.get("targets", []): 460 | if tgt in object_map and tgt not in visited_for_context: # Check if tgt is valid 461 | if object_map[tgt]['is_comp'] or (object_map[tgt]['is_param'] and not object_map[tgt]['parent_guid']): 462 | next_level.add(tgt) 463 | visited_for_context.add(tgt) 464 | context_guids_to_add.add(tgt) 465 | 466 | if not next_level: break 467 | current_level = next_level 468 | 469 | # --- Pass 3: Get info for context objects --- 470 | for context_guid_str in context_guids_to_add: 471 | if context_guid_str not in guids_added_to_result: # Avoid reprocessing 472 | if context_guid_str in object_map: 473 | item = object_map[context_guid_str] 474 | info = None 475 | is_selected_flag = False # Context objects are not selected 476 | 477 | if item['is_comp']: 478 | info = get_component_info(item['obj'], simplified=simplified, is_selected=is_selected_flag) 479 | elif item['is_param'] and not item['parent_guid']: # Standalone only 480 | info = get_standalone_param_info(item['obj'], simplified=simplified, is_selected=is_selected_flag) 481 | 482 | if info: 483 | result_graph[context_guid_str] = info 484 | guids_added_to_result.add(context_guid_str) 485 | 486 | 487 | # --- Final Step: Sort the resulting graph --- 488 | # No need to filter child params here, as we only added components and standalone params 489 | if result_graph: 490 | return sort_graph_by_execution_order(result_graph) 491 | else: 492 | return {} 493 | 494 | 495 | def get_object_by_instance_guid(doc, instance_guid): 496 | """ 497 | Helper function to get an object from document by instance GUID string. 498 | 499 | Args: 500 | doc: Grasshopper document 501 | instance_guid: Instance GUID string 502 | 503 | Returns: 504 | Grasshopper object if found, None otherwise 505 | """ 506 | try: 507 | import System 508 | if isinstance(instance_guid, str): 509 | instance_guid = System.Guid(instance_guid) 510 | 511 | for obj in doc.Objects: 512 | if obj.InstanceGuid == instance_guid: 513 | return obj 514 | except: 515 | pass 516 | 517 | return None 518 | 519 | # ======== edit code component 520 | 521 | 522 | def update_script_component(instance_guid, code=None, description=None, message_to_user=None, param_definitions=None): 523 | """ 524 | Updates a script component identified by its GUID with new code, description, 525 | a user message, and optionally new input/output parameters. 526 | 527 | Args: 528 | instance_guid (str): The GUID of the target script component. 529 | code (str, optional): New code for the component. 530 | description (str, optional): New component description. 531 | message_to_user (str, optional): Message to set on an output parameter. 532 | param_definitions (list of dict, optional): List of dictionaries defining parameters. 533 | Each dictionary must have: 534 | - "type": "input" or "output" 535 | - "name": a string 536 | Optional keys for inputs: 537 | - "access": "item", "list", or "tree" (default "list") 538 | - "typehint": e.g. "str", "int", "float", "bool" (determines parameter type) 539 | - "description": text to display for the parameter (falls back to default_description) 540 | - "optional": bool, default True 541 | - "default": a default value (not set persistently) 542 | 543 | Returns: 544 | dict: Status and result details. 545 | """ 546 | default_description="Dynamically added parameter" 547 | # always set mesage_to_user as output 548 | #code = code + "\n\n# Display message to user\noutput = " + repr(message_to_user) 549 | try: 550 | # Get the Grasshopper document and find the target component by instance GUID. 551 | doc = ghenv.Component.OnPingDocument() 552 | target_instance_guid = Guid.Parse(instance_guid) 553 | comp = doc.FindObject(target_instance_guid, False) 554 | if comp is None: 555 | return {"status": "error", "result": "Component with instance GUID {} not found.".format(instance_guid)} 556 | 557 | 558 | gh.Instances.ActiveCanvas.Enabled = False 559 | 560 | try: 561 | # If parameter definitions are provided, update parameters. 562 | if param_definitions is not None: 563 | try: 564 | # Step 1: Add temporary dummy parameters to prevent crashes 565 | dummy_input = create_input_param({"description": "Temporary parameter"}, "__dummy_input__", default_description) 566 | dummy_output = create_output_param({"description": "Temporary parameter"}, "__dummy_output__") 567 | 568 | comp.Params.RegisterInputParam(dummy_input) 569 | comp.Params.RegisterOutputParam(dummy_output) 570 | 571 | # Step 2: Clear existing inputs and outputs (except dummies) 572 | for p in list(comp.Params.Input): 573 | if p.Name != "__dummy_input__": 574 | comp.Params.UnregisterInputParameter(p) 575 | 576 | for p in list(comp.Params.Output): 577 | if p.Name != "__dummy_output__": 578 | comp.Params.UnregisterOutputParameter(p) 579 | 580 | # Step 3: Process and add the new parameters 581 | # Process inputs 582 | inputs = [d for d in param_definitions if d.get("type", "").lower() == "input"] 583 | for d in inputs: 584 | if "name" not in d: 585 | continue 586 | name = d["name"] 587 | new_param = create_input_param(d, name, default_description) 588 | comp.Params.RegisterInputParam(new_param) 589 | 590 | # Process outputs 591 | outputs = [d for d in param_definitions if d.get("type", "").lower() == "output"] 592 | for d in outputs: 593 | if "name" not in d: 594 | continue 595 | name = d["name"] 596 | new_param = create_output_param(d, name) 597 | comp.Params.RegisterOutputParam(new_param) 598 | 599 | # Ensure there is always an output parameter named "output" 600 | out_names = [d.get("name", "").lower() for d in outputs] 601 | if "output" not in out_names: 602 | default_out = create_output_param({"description": "Default output"}, "output") 603 | comp.Params.RegisterOutputParam(default_out) 604 | 605 | # Step 4: Remove the dummy parameters now that we have real parameters 606 | comp.Params.UnregisterInputParameter(dummy_input) 607 | comp.Params.UnregisterOutputParameter(dummy_output) 608 | 609 | # Clear component data and cache 610 | comp.ClearData() 611 | 612 | except Exception as e: 613 | return {"status": "error", "result": "Error updating parameters: {}".format(str(e))} 614 | 615 | # Update code if provided. 616 | if code is not None: 617 | try: 618 | if hasattr(comp, "Code"): 619 | comp.Code = code 620 | else: 621 | return {"status": "error", "result": "Component does not have a Code attribute. Component type: {}".format(type(comp).__name__)} 622 | except Exception as e: 623 | return {"status": "error", "result": "Error updating code: {}".format(str(e))} 624 | 625 | # Update component description if provided. 626 | if description is not None: 627 | try: 628 | comp.Description = description 629 | except Exception as e: 630 | return {"status": "error", "result": "Error updating description: {}".format(str(e))} 631 | 632 | # Set a message to the user if provided (attempting to add volatile data to first output). 633 | if message_to_user is not None: 634 | try: 635 | if hasattr(comp, "Params") and hasattr(comp.Params, "Output") and len(comp.Params.Output) > 0: 636 | comp.Params.Output[0].AddVolatileData(Grasshopper.Kernel.Data.GH_Path(0), 0, message_to_user) 637 | except Exception as e: 638 | return {"status": "error", "result": "Error setting message: {}".format(str(e))} 639 | 640 | # Force component to recompute. 641 | if hasattr(comp, "Attributes"): 642 | comp.Attributes.ExpireLayout() 643 | comp.ExpireSolution(True) 644 | 645 | return { 646 | "status": "success", 647 | "result": { 648 | "code_updated": code is not None, 649 | "description_updated": description is not None, 650 | "message_set": message_to_user is not None, 651 | "component_type": type(comp).__name__ 652 | } 653 | } 654 | finally: 655 | # CRITICAL: Always unfreeze the UI, even if an error occurs 656 | doc.DestroyAttributeCache() 657 | gh.Instances.ActiveCanvas.Enabled = True 658 | 659 | except Exception as e: 660 | return {"status": "error", "result": "General error updating component: {}".format(str(e))} 661 | 662 | 663 | def get_access(access_str): 664 | """ 665 | Convert a string ("item", "list", or "tree") to GH_ParamAccess. 666 | Defaults to list. 667 | """ 668 | if isinstance(access_str, basestring): 669 | s = access_str.lower() 670 | if s == "item": 671 | return GH_ParamAccess.item 672 | elif s == "tree": 673 | return GH_ParamAccess.tree 674 | return GH_ParamAccess.list 675 | 676 | def create_input_param(d, name, default_description): 677 | """ 678 | Creates an input parameter based on the dictionary. 679 | Chooses a parameter type based on 'typehint' if provided. 680 | Does not set the TypeHint property to keep the right-click menu active. 681 | """ 682 | hint = d.get("typehint", "").lower() 683 | if hint in ["str", "string"]: 684 | param = Param_String() 685 | elif hint in ["int", "integer"]: 686 | param = Param_Integer() 687 | elif hint in ["float", "number", "double"]: 688 | param = Param_Number() 689 | elif hint in ["bool", "boolean"]: 690 | param = Param_Boolean() 691 | else: 692 | param = Param_GenericObject() 693 | 694 | param.NickName = name 695 | param.Name = name 696 | param.Description = d.get("description", default_description) 697 | param.Access = get_access(d.get("access", "list")) 698 | param.Optional = d.get("optional", True) 699 | 700 | return param 701 | 702 | def create_output_param(d, name): 703 | """ 704 | Creates an output parameter. 705 | For outputs, we simply create a generic parameter. 706 | """ 707 | param = Param_GenericObject() 708 | param.NickName = name 709 | param.Name = name 710 | param.Description = d.get("description", "Dynamically added output") 711 | 712 | return param 713 | 714 | 715 | 716 | def update_script_with_code_reference(instance_guid, file_path=None, param_definitions=None, description=None, name=None, force_code_reference=False): 717 | """ 718 | Updates a script component to use code from an external file. 719 | """ 720 | doc = ghenv.Component.OnPingDocument() 721 | target_guid = System.Guid.Parse(instance_guid) 722 | comp = doc.FindObject(target_guid, False) 723 | 724 | if comp is None: 725 | return {"status": "error", "result": "Component not found"} 726 | 727 | result = {"status": "error", "result": "Unknown error"} 728 | 729 | # Freeze the canvas 730 | gh.Instances.ActiveCanvas.Enabled = False 731 | 732 | try: 733 | if force_code_reference: 734 | # APPROACH 1: Try to completely reset the component's internal state 735 | 736 | # First, clear any existing code 737 | if hasattr(comp, "Code"): 738 | # Set component's code to a minimal script that forces external code usage 739 | comp.Code = "# This component is set to use external code" 740 | 741 | # Toggle InputIsPath off and on 742 | comp.InputIsPath = False 743 | comp.InputIsPath = True 744 | 745 | # Remove any existing code parameter 746 | for p in list(comp.Params.Input): 747 | if p.Name == "code": 748 | comp.Params.UnregisterInputParameter(p) 749 | 750 | # Create fresh code parameter 751 | code_param = comp.ConstructCodeInputParameter() 752 | code_param.NickName = "code" 753 | code_param.Name = "code" 754 | code_param.Description = "Path to Python code file" 755 | 756 | # Force a rebuild of the component 757 | comp.ClearData() 758 | comp.Attributes.ExpireLayout() 759 | comp.ExpireSolution(True) 760 | doc.DestroyAttributeCache() 761 | 762 | # Register the parameter 763 | comp.Params.RegisterInputParam(code_param) 764 | 765 | # Set InputIsPath again 766 | comp.InputIsPath = True 767 | 768 | # Set file path if provided 769 | if file_path is not None: 770 | code_param.AddVolatileData(Grasshopper.Kernel.Data.GH_Path(0), 0, file_path) 771 | 772 | # APPROACH 2: Try manipulating document-level features to force update 773 | comp.Phase = Grasshopper.Kernel.GH_SolutionPhase.Blank # Reset solution phase 774 | 775 | # Force the component to be "dirty" to ensure recomputation 776 | if hasattr(comp, "OnPingDocument"): 777 | comp_doc = comp.OnPingDocument() 778 | if comp_doc: 779 | comp_doc.ScheduleSolution(5) # Schedule a solution update 780 | 781 | # Handle param definitions but preserve code input 782 | if param_definitions is not None: 783 | default_description = "Dynamically added parameter" 784 | 785 | # Add temporary dummy parameters 786 | dummy_input = create_input_param({"description": "Temporary parameter"}, "__dummy_input__", default_description) 787 | dummy_output = create_output_param({"description": "Temporary parameter"}, "__dummy_output__") 788 | 789 | comp.Params.RegisterInputParam(dummy_input) 790 | comp.Params.RegisterOutputParam(dummy_output) 791 | 792 | # Clear existing inputs/outputs except code input and dummies 793 | for p in list(comp.Params.Input): 794 | if p.Name != "__dummy_input__" and p.Name != "code": 795 | comp.Params.UnregisterInputParameter(p) 796 | 797 | for p in list(comp.Params.Output): 798 | if p.Name != "__dummy_output__": 799 | comp.Params.UnregisterOutputParameter(p) 800 | 801 | # Add new inputs (skip code parameter) 802 | inputs = [d for d in param_definitions if d.get("type", "").lower() == "input" 803 | and d.get("name", "").lower() != "code"] 804 | 805 | for d in inputs: 806 | if "name" not in d: 807 | continue 808 | name_val = d["name"] 809 | new_param = create_input_param(d, name_val, default_description) 810 | comp.Params.RegisterInputParam(new_param) 811 | 812 | # Add new outputs 813 | outputs = [d for d in param_definitions if d.get("type", "").lower() == "output"] 814 | for d in outputs: 815 | if "name" not in d: 816 | continue 817 | name_val = d["name"] 818 | new_param = create_output_param(d, name_val) 819 | comp.Params.RegisterOutputParam(new_param) 820 | 821 | # Ensure there's always an output parameter 822 | out_names = [d.get("name", "").lower() for d in outputs] 823 | if "output" not in out_names: 824 | default_out = create_output_param({"description": "Default output"}, "output") 825 | comp.Params.RegisterOutputParam(default_out) 826 | 827 | # Remove dummy parameters 828 | comp.Params.UnregisterInputParameter(dummy_input) 829 | comp.Params.UnregisterOutputParameter(dummy_output) 830 | 831 | # Update description and name if provided 832 | if description is not None: 833 | comp.Description = description 834 | 835 | if name is not None: 836 | comp.NickName = name 837 | 838 | # Final force update 839 | comp.ClearData() 840 | comp.Attributes.ExpireLayout() 841 | comp.ExpireSolution(True) 842 | 843 | result = { 844 | "status": "success", 845 | "result": { 846 | "code_reference_enforced": force_code_reference, 847 | "file_path_set": file_path is not None, 848 | "description_updated": description is not None, 849 | "name_updated": name is not None 850 | } 851 | } 852 | except Exception as e: 853 | result = {"status": "error", "result": str(e) + " remember in case of erros/bugs in code you need to fix it in the reference python file "} 854 | finally: 855 | # Always unfreeze the UI 856 | doc.DestroyAttributeCache() 857 | gh.Instances.ActiveCanvas.Enabled = True 858 | 859 | return result 860 | 861 | 862 | #====== 863 | 864 | def get_selected_components(simplified=False, context_depth=0): 865 | """ 866 | Get currently selected components and parameters in the Grasshopper document. 867 | 868 | Args: 869 | simplified: Whether to return simplified object info 870 | context_depth: How many levels up/downstream to include (0-3) 871 | 872 | Returns: 873 | Dictionary of selected objects, keyed by their instance GUID 874 | """ 875 | # Get the current Grasshopper document 876 | doc = ghenv.Component.OnPingDocument() 877 | if not doc: 878 | return {"error": "No active Grasshopper document"} 879 | 880 | # Find all selected objects 881 | selected_guids = [] 882 | for obj in doc.Objects: 883 | if hasattr(obj, "Attributes") and hasattr(obj.Attributes, "Selected") and obj.Attributes.Selected: 884 | selected_guids.append(str(obj.InstanceGuid)) 885 | 886 | # If no objects are selected, return empty result 887 | if not selected_guids: 888 | return {} 889 | 890 | # Use the existing get_objects function to get details about the selected objects 891 | # This also handles the context_depth parameter 892 | result = get_objects(selected_guids, context_depth=context_depth, simplified=simplified) 893 | 894 | return result 895 | 896 | def get_grasshopper_context(simplified=False): 897 | """ 898 | Get information about the current Grasshopper document and its components. 899 | 900 | Args: 901 | simplified: Whether to return simplified graph info 902 | 903 | Returns: 904 | Dictionary with graph information, always sorted by execution order 905 | """ 906 | try: 907 | # Get the current Grasshopper document 908 | doc = ghenv.Component.OnPingDocument() 909 | if not doc: 910 | return {"error": "No active Grasshopper document"} 911 | 912 | # Initialize graph dictionary 913 | IO_graph = {} 914 | 915 | # Get all objects in the document 916 | for obj in doc.Objects: 917 | is_selected = hasattr(obj, "Attributes") and hasattr(obj.Attributes, "Selected") and obj.Attributes.Selected 918 | 919 | if isinstance(obj, Grasshopper.Kernel.IGH_Component): 920 | comp_info = get_component_info(obj, simplified=simplified, is_selected=is_selected) 921 | IO_graph[str(obj.InstanceGuid)] = comp_info 922 | elif isinstance(obj, Grasshopper.Kernel.IGH_Param): 923 | # Handle standalone parameters 924 | param_info = get_standalone_param_info(obj, simplified=simplified, is_selected=is_selected) 925 | IO_graph[str(obj.InstanceGuid)] = param_info 926 | 927 | # Fill in sources based on targets for comprehensive connections 928 | for node_id, node in IO_graph.items(): 929 | for target_id in node["targets"]: 930 | if target_id in IO_graph and node_id not in IO_graph[target_id]["sources"]: 931 | IO_graph[target_id]["sources"].append(node_id) 932 | 933 | # Always sort the graph by execution order 934 | if IO_graph: 935 | IO_graph = sort_graph_by_execution_order(IO_graph) 936 | 937 | return { 938 | "status": "success", 939 | "graph": IO_graph 940 | } 941 | except Exception as e: 942 | print("Error in get_grasshopper_context: " + str(e)) 943 | return {"error": str(e)} 944 | 945 | def receive_full_request(conn): 946 | """Receive the complete HTTP request.""" 947 | data = b'' 948 | while True: 949 | chunk = conn.recv(1048576) 950 | if not chunk: 951 | break 952 | data += chunk 953 | if b'\r\n\r\n' in data: # Found end of headers 954 | break 955 | return data.decode('utf-8') 956 | 957 | def respond(conn, response_dict): 958 | """Send an HTTP response with JSON content and close the connection.""" 959 | json_response = json.dumps(response_dict, cls=GHEncoder) 960 | http_response = ( 961 | "HTTP/1.1 200 OK\r\n" 962 | "Content-Type: application/json\r\n" 963 | "Content-Length: {}\r\n" 964 | "Access-Control-Allow-Origin: *\r\n" 965 | "Connection: close\r\n" 966 | "\r\n" 967 | "{}" 968 | ).format(len(json_response), json_response) 969 | try: 970 | conn.sendall(http_response.encode('utf-8')) 971 | finally: 972 | try: 973 | conn.shutdown(socket.SHUT_RDWR) 974 | except: 975 | pass 976 | 977 | def parse_command(data): 978 | """Parse the incoming command data into a structured format.""" 979 | try: 980 | command_data = json.loads(data) 981 | if isinstance(command_data, dict): 982 | return command_data 983 | return {"type": "raw", "data": data} 984 | except json.JSONDecodeError: 985 | return {"type": "raw", "data": data} 986 | 987 | def execute_code(code_str): 988 | """Execute Python code string and return the result.""" 989 | try: 990 | # Create a new dictionary for local variables 991 | local_vars = {} 992 | 993 | # Execute the code with access to the current context 994 | exec(code_str, globals(), local_vars) 995 | 996 | # If there's a result variable defined, return it 997 | if 'result' in local_vars: 998 | return {"status": "success", "result": local_vars['result']} 999 | return {"status": "success", "result": "Code executed successfully"} 1000 | except Exception as e: 1001 | print("Code execution error: " + str(e)) 1002 | return {"status": "error", "result": str(e)} 1003 | 1004 | def process_command(command_data): 1005 | """Process a command and return the result.""" 1006 | command_type = command_data.get("type", "raw") 1007 | 1008 | if command_type == "raw": 1009 | # Handle legacy raw text commands 1010 | raw_data = command_data["data"] 1011 | if raw_data == "fetch_new_data": 1012 | return {"result": "Fetched new data!", "status": "success"} 1013 | else: 1014 | return {"result": "Unknown command: " + raw_data, "status": "error"} 1015 | 1016 | elif command_type == "test_command": 1017 | # Handle test command with dummy response 1018 | params = command_data.get("params", {}) 1019 | return { 1020 | "status": "success", 1021 | "result": { 1022 | "message": "Test command executed successfully", 1023 | "received_params": params, 1024 | "dummy_data": {"value": 42, "text": "Hello from Grasshopper!"} 1025 | } 1026 | } 1027 | 1028 | elif command_type == "get_context": 1029 | # Get Grasshopper context, with option for simplified view 1030 | simplified = command_data.get("simplified", False) 1031 | context = get_grasshopper_context(simplified=simplified) 1032 | if "error" in context: 1033 | return {"status": "error", "result": context} 1034 | return { 1035 | "status": "success", 1036 | "result": context 1037 | } 1038 | 1039 | elif command_type == "get_object" or command_type == "get_objects": 1040 | # Get objects by instance GUIDs with optional context depth 1041 | if command_type == "get_object": 1042 | instance_guid = command_data.get("instance_guid") 1043 | if not instance_guid: 1044 | return {"status": "error", "result": "No instance GUID provided"} 1045 | instance_guids = [instance_guid] 1046 | else: 1047 | instance_guids = command_data.get("instance_guids", []) 1048 | if not instance_guids: 1049 | return {"status": "error", "result": "No instance GUIDs provided"} 1050 | 1051 | simplified = command_data.get("simplified", False) 1052 | context_depth = command_data.get("context_depth", 0) 1053 | 1054 | # Validate context_depth (0-3) 1055 | try: 1056 | context_depth = int(context_depth) 1057 | if context_depth < 0: 1058 | context_depth = 0 1059 | elif context_depth > 3: 1060 | context_depth = 3 1061 | except: 1062 | context_depth = 0 1063 | 1064 | result = get_objects(instance_guids, context_depth=context_depth, simplified=simplified) 1065 | 1066 | if not result: 1067 | return {"status": "error", "result": "Objects not found"} 1068 | 1069 | return { 1070 | "status": "success", 1071 | "result": result 1072 | } 1073 | 1074 | elif command_type == "get_selected": 1075 | # Get selected components/parameters with optional context 1076 | simplified = command_data.get("simplified", False) 1077 | context_depth = command_data.get("context_depth", 0) 1078 | 1079 | # Validate context_depth (0-3) 1080 | try: 1081 | context_depth = int(context_depth) 1082 | if context_depth < 0: 1083 | context_depth = 0 1084 | elif context_depth > 3: 1085 | context_depth = 3 1086 | except: 1087 | context_depth = 0 1088 | 1089 | selected = get_selected_components(simplified=simplified, context_depth=context_depth) 1090 | return { 1091 | "status": "success", 1092 | "result": selected 1093 | } 1094 | 1095 | elif command_type == "update_script": 1096 | # Update a script component (now also accepting param_definitions) 1097 | # Accept either instance_guid or component_guid (for backward compatibility) 1098 | instance_guid = command_data.get("instance_guid") 1099 | if not instance_guid: 1100 | # Fall back to component_guid if instance_guid is not provided 1101 | instance_guid = command_data.get("component_guid") 1102 | if not instance_guid: 1103 | return {"status": "error", "result": "No instance_guid provided"} 1104 | 1105 | code = command_data.get("code") 1106 | description = command_data.get("description") 1107 | message_to_user = command_data.get("message_to_user") 1108 | # New: Get dynamic parameter definitions if supplied. 1109 | param_definitions = command_data.get("param_definitions") 1110 | 1111 | result = update_script_component( 1112 | instance_guid, 1113 | code=code, 1114 | description=description, 1115 | message_to_user=message_to_user, 1116 | param_definitions=param_definitions 1117 | ) 1118 | 1119 | return result 1120 | 1121 | elif command_type == "update_script_with_code_reference": 1122 | # Update a script component to use code from an external file 1123 | # Accept either instance_guid or component_guid (for backward compatibility) 1124 | instance_guid = command_data.get("instance_guid") 1125 | if not instance_guid: 1126 | # Fall back to component_guid if instance_guid is not provided 1127 | instance_guid = command_data.get("component_guid") 1128 | if not instance_guid: 1129 | return {"status": "error", "result": "No instance_guid provided"} 1130 | 1131 | # Get parameters for the update operation 1132 | file_path = command_data.get("file_path") 1133 | param_definitions = command_data.get("param_definitions") 1134 | description = command_data.get("description") 1135 | name = command_data.get("name") 1136 | force_code_reference = command_data.get("force_code_reference", False) 1137 | 1138 | result = update_script_with_code_reference( 1139 | instance_guid, 1140 | file_path=file_path, 1141 | param_definitions=param_definitions, 1142 | description=description, 1143 | name=name, 1144 | force_code_reference=force_code_reference 1145 | ) 1146 | 1147 | return result 1148 | 1149 | elif command_type == "execute_code": 1150 | # Execute Python code 1151 | code = command_data.get("code", "") 1152 | if not code: 1153 | return {"status": "error", "result": "No code provided"} 1154 | return execute_code(code) 1155 | 1156 | else: 1157 | return {"result": "Unknown command type: " + command_type, "status": "error"} 1158 | 1159 | def socket_server(): 1160 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 1161 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 1162 | host = "127.0.0.1" # Bind to localhost 1163 | port = 9999 # Use port 9999 1164 | s.bind((host, port)) 1165 | s.listen(5) # Allow up to 5 pending connections 1166 | print("Socket server listening on {}:{}".format(host, port)) 1167 | A = "Socket server listening on 127.0.0.1:9999" 1168 | 1169 | while True: 1170 | try: 1171 | s.settimeout(1.0) # Check for new connections every second. 1172 | try: 1173 | conn, addr = s.accept() 1174 | conn.settimeout(5.0) # Set timeout for receiving data 1175 | except socket.timeout: 1176 | continue 1177 | 1178 | try: 1179 | full_data = receive_full_request(conn) 1180 | if full_data: 1181 | sc.sticky["fullData"] = full_data 1182 | if not full_data: 1183 | continue 1184 | 1185 | # Extract the payload from the HTTP request (ignoring headers) 1186 | parts = full_data.split("\r\n\r\n") 1187 | if len(parts) > 1: 1188 | command = parts[1].strip() 1189 | else: 1190 | command = full_data.strip() 1191 | 1192 | # Parse the command into a structured format 1193 | command_data = parse_command(command) 1194 | sc.sticky["commandData"] = command_data 1195 | print("Received command: " + str(command_data)) 1196 | 1197 | # Handle stop command 1198 | if command_data.get("type") == "stop": 1199 | print("Received stop command. Closing server.") 1200 | respond(conn, {"status": "stopping", "message": "Server is shutting down."}) 1201 | conn.close() 1202 | break 1203 | 1204 | # Process command immediately and store result 1205 | result = process_command(command_data) 1206 | sc.sticky["last_result"] = result 1207 | 1208 | # Send response with result 1209 | response = { 1210 | "status": result["status"], 1211 | "result": result["result"], 1212 | "command_type": command_data.get("type", "raw") 1213 | } 1214 | respond(conn, response) 1215 | except Exception as e: 1216 | print("Error handling request: " + str(e)) 1217 | error_response = { 1218 | "status": "error", 1219 | "result": str(e)+ str(sc.sticky["last_result"]), 1220 | "command_type": "error" 1221 | } 1222 | respond(conn, error_response) 1223 | finally: 1224 | try: 1225 | conn.close() 1226 | except: 1227 | pass 1228 | except Exception as e: 1229 | print("Socket server error: " + str(e)+ str(sc.sticky["last_result"])) 1230 | break 1231 | s.close() 1232 | sc.sticky["server_running"] = False 1233 | print("Socket server closed.") 1234 | 1235 | # Start the socket server if it isn't already running. 1236 | if not sc.sticky["server_running"]: 1237 | sc.sticky["server_running"] = True 1238 | thread = threading.Thread(target=socket_server) 1239 | thread.daemon = True 1240 | thread.start() 1241 | 1242 | # Main SolveInstance processing: 1243 | if sc.sticky["last_result"]: 1244 | result = sc.sticky["last_result"] 1245 | sc.sticky["last_result"] = None # Clear the result after processing 1246 | A = "Last command result: " + json.dumps(result, cls=GHEncoder) # Use custom encoder here too 1247 | 1248 | else: 1249 | A = "Waiting for command..." --------------------------------------------------------------------------------