chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합
- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환 - Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용 - pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관) - worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md) - D:\Rhino-MCP 경로 참조를 새 위치로 치환 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
964a841de1
3978 files changed
+58013356
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# copy this file to .env and fill in the values
|
||||
REPLICATE_TOKEN = <your replicate token>
|
||||
@@ -0,0 +1,39 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 RhinoMCP Team
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RhinoMCP - Main entry point
|
||||
|
||||
This script serves as a convenience wrapper to start the RhinoMCP server.
|
||||
"""
|
||||
|
||||
from rhino_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
[project]
|
||||
name = "rhino-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Rhino integration through the Model Context Protocol"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{name = "Rhino MCP Team"}
|
||||
]
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.3.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
rhino-mcp = "rhino_mcp.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "."}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Core MCP dependencies
|
||||
mcp-python>=0.1.0
|
||||
fastmcp>=0.1.0
|
||||
|
||||
# Server dependencies
|
||||
uvicorn>=0.15.0
|
||||
fastapi>=0.68.0
|
||||
|
||||
# Utility dependencies
|
||||
python-json-logger>=2.0.0
|
||||
pydantic>=1.8.0
|
||||
typing-extensions>=4.0.0
|
||||
requests
|
||||
pillow
|
||||
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Rhino Model Context Protocol (MCP) Integration
|
||||
----------------------------------------------
|
||||
A package that allows Claude to interact with Rhino through the Model Context Protocol
|
||||
"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,733 @@
|
||||
"""Tools for interacting with Grasshopper through socket connection."""
|
||||
from mcp.server.fastmcp import FastMCP, Context, Image
|
||||
import logging
|
||||
from typing import Dict, Any, List, Optional, Union
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image as PILImage
|
||||
import requests
|
||||
import re
|
||||
from urllib3.exceptions import InsecureRequestWarning
|
||||
import urllib3
|
||||
|
||||
# Disable insecure HTTPS warnings
|
||||
urllib3.disable_warnings(InsecureRequestWarning)
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger("GrasshopperTools")
|
||||
|
||||
# Add a preprocessing function for LLM inputs
|
||||
def preprocess_llm_input(input_str: str) -> str:
|
||||
"""
|
||||
Preprocess a potentially malformed JSON string from an LLM.
|
||||
This handles common issues before attempting JSON parsing.
|
||||
|
||||
Args:
|
||||
input_str: Raw string from LLM that may contain malformed JSON
|
||||
|
||||
Returns:
|
||||
Preprocessed string that should be easier to parse
|
||||
"""
|
||||
if not isinstance(input_str, str):
|
||||
return input_str
|
||||
|
||||
# Replace backtick delimiters with proper double quotes for the entire JSON object
|
||||
if input_str.strip().startswith('`{') and input_str.strip().endswith('}`'):
|
||||
input_str = input_str.strip()[1:-1] # Remove the outer backticks
|
||||
|
||||
# Handle backtick-delimited field names and string values
|
||||
# This is a basic approach - first convert all standalone backtick pairs to double quotes
|
||||
result = ""
|
||||
in_string = False
|
||||
last_char = None
|
||||
i = 0
|
||||
|
||||
while i < len(input_str):
|
||||
char = input_str[i]
|
||||
|
||||
# Handle backtick as quote
|
||||
if char == '`' and (last_char is None or last_char != '\\'):
|
||||
in_string = not in_string
|
||||
result += '"'
|
||||
else:
|
||||
result += char
|
||||
|
||||
last_char = char
|
||||
i += 1
|
||||
|
||||
# Fix boolean values
|
||||
result = re.sub(r':\s*True\b', ': true', result)
|
||||
result = re.sub(r':\s*False\b', ': false', result)
|
||||
result = re.sub(r':\s*None\b', ': null', result)
|
||||
|
||||
return result
|
||||
|
||||
def extract_payload_fields(raw_input: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract fields from a payload that might be malformed.
|
||||
Works with raw LLM output directly.
|
||||
|
||||
Args:
|
||||
raw_input: Raw string input from LLM
|
||||
|
||||
Returns:
|
||||
Dictionary of extracted fields
|
||||
"""
|
||||
if not isinstance(raw_input, str):
|
||||
return {}
|
||||
|
||||
# First attempt: try the standard JSON sanitizer
|
||||
payload = sanitize_json(raw_input)
|
||||
if payload:
|
||||
return payload
|
||||
|
||||
# Second attempt: special handling for backtick-delimited code
|
||||
if '`code`' in raw_input or '"code"' in raw_input:
|
||||
# Find the code section
|
||||
code_match = re.search(r'[`"]code[`"]\s*:\s*[`"](.*?)[`"](?=\s*,|\s*\})', raw_input, re.DOTALL)
|
||||
instance_guid_match = re.search(r'[`"]instance_guid[`"]\s*:\s*[`"](.*?)[`"]', raw_input)
|
||||
message_match = re.search(r'[`"]message_to_user[`"]\s*:\s*[`"](.*?)[`"]', raw_input)
|
||||
|
||||
result = {}
|
||||
|
||||
if instance_guid_match:
|
||||
result["instance_guid"] = instance_guid_match.group(1)
|
||||
|
||||
if code_match:
|
||||
result["code"] = code_match.group(1)
|
||||
|
||||
if message_match:
|
||||
result["message_to_user"] = message_match.group(1)
|
||||
|
||||
return result
|
||||
|
||||
return {}
|
||||
|
||||
# Update the sanitize_json function to use the preprocessor
|
||||
def sanitize_json(json_str_or_dict: Union[str, Dict]) -> Dict[str, Any]:
|
||||
"""
|
||||
Sanitize and validate JSON input, which might come from an LLM.
|
||||
|
||||
Args:
|
||||
json_str_or_dict: Either a JSON string or dictionary that might need sanitizing
|
||||
|
||||
Returns:
|
||||
A properly formatted dictionary
|
||||
"""
|
||||
# If it's already a dictionary, return it
|
||||
if isinstance(json_str_or_dict, dict):
|
||||
return json_str_or_dict.copy()
|
||||
|
||||
# If it's a string, try to fix common issues
|
||||
if isinstance(json_str_or_dict, str):
|
||||
# Apply preprocessing for LLM input
|
||||
json_str = preprocess_llm_input(json_str_or_dict)
|
||||
|
||||
# Remove markdown JSON code block markers if present
|
||||
json_str = re.sub(r'^```json\s*', '', json_str)
|
||||
json_str = re.sub(r'\s*```$', '', json_str)
|
||||
|
||||
# Try to parse the JSON
|
||||
try:
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON after preprocessing: {e}")
|
||||
logger.error(f"Preprocessed JSON string: {json_str}")
|
||||
|
||||
# Try another approach - remove all newlines from outside code sections
|
||||
try:
|
||||
# Find code sections
|
||||
if '"code"' in json_str:
|
||||
parts = []
|
||||
last_end = 0
|
||||
|
||||
# Find all code sections
|
||||
for match in re.finditer(r'"code"\s*:\s*"(.*?)"(?=\s*,|\s*\})', json_str, re.DOTALL):
|
||||
# Add the part before code with newlines removed
|
||||
before_code = json_str[last_end:match.start()]
|
||||
before_code = re.sub(r'\s+', ' ', before_code)
|
||||
parts.append(before_code)
|
||||
|
||||
# Add the code section as is
|
||||
code_section = match.group(0)
|
||||
parts.append(code_section)
|
||||
|
||||
last_end = match.end()
|
||||
|
||||
# Add the remaining part
|
||||
remaining = json_str[last_end:]
|
||||
remaining = re.sub(r'\s+', ' ', remaining)
|
||||
parts.append(remaining)
|
||||
|
||||
# Combine all parts
|
||||
json_str = ''.join(parts)
|
||||
|
||||
return json.loads(json_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse JSON with alternative method")
|
||||
|
||||
# Return empty dict as fallback
|
||||
return {}
|
||||
|
||||
# If it's neither a dict nor string, return empty dict
|
||||
return {}
|
||||
|
||||
class GrasshopperConnection:
|
||||
def __init__(self, host='localhost', port=9999): # Using port 9999 to match gh_socket_server.py
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.base_url = f"http://{host}:{port}"
|
||||
self.timeout = 30.0 # 30 second timeout
|
||||
|
||||
def check_server_available(self) -> bool:
|
||||
"""Check if the Grasshopper server is running and available.
|
||||
|
||||
Returns:
|
||||
bool: True if the server is available, False otherwise
|
||||
"""
|
||||
try:
|
||||
response = requests.get(self.base_url, timeout=2.0)
|
||||
response.raise_for_status()
|
||||
logger.info("Grasshopper server is available at {0}".format(self.base_url))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Grasshopper server is not available: {0}".format(str(e)))
|
||||
return False
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the Grasshopper script's HTTP server"""
|
||||
# Check if server is available
|
||||
if not self.check_server_available():
|
||||
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))
|
||||
logger.info("Connected to Grasshopper server")
|
||||
|
||||
def disconnect(self):
|
||||
"""No need to disconnect for HTTP connections"""
|
||||
pass
|
||||
|
||||
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Send a command to the Grasshopper script and wait for response"""
|
||||
try:
|
||||
data = {
|
||||
"type": command_type,
|
||||
**(params or {})
|
||||
}
|
||||
|
||||
logger.info(f"Sending command to Grasshopper server: type={command_type}")
|
||||
|
||||
# Use a session to handle connection properly
|
||||
with requests.Session() as session:
|
||||
response = session.post(
|
||||
self.base_url,
|
||||
json=data,
|
||||
timeout=self.timeout,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
stream=True
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Read the response content and return it directly
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as req_err:
|
||||
error_content = ""
|
||||
if hasattr(req_err, 'response') and req_err.response is not None:
|
||||
try:
|
||||
error_content = req_err.response.text
|
||||
except:
|
||||
pass
|
||||
|
||||
error_msg = f"HTTP request error: {str(req_err)}. Response: {error_content}"
|
||||
logger.error(error_msg)
|
||||
return {"status": "error", "result": error_msg}
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error communicating with Grasshopper script: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"status": "error", "result": error_msg}
|
||||
|
||||
# Global connection instance
|
||||
_grasshopper_connection = None
|
||||
|
||||
def get_grasshopper_connection() -> GrasshopperConnection:
|
||||
"""Get or create the Grasshopper connection"""
|
||||
global _grasshopper_connection
|
||||
if _grasshopper_connection is None:
|
||||
_grasshopper_connection = GrasshopperConnection()
|
||||
return _grasshopper_connection
|
||||
|
||||
class GrasshopperTools:
|
||||
"""Collection of tools for interacting with Grasshopper."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self._register_tools()
|
||||
|
||||
def _register_tools(self):
|
||||
"""Register all Grasshopper tools with the MCP server."""
|
||||
self.app.tool()(self.is_server_available)
|
||||
self.app.tool()(self.execute_code_in_gh)
|
||||
self.app.tool()(self.get_gh_context)
|
||||
self.app.tool()(self.get_objects)
|
||||
self.app.tool()(self.get_selected)
|
||||
self.app.tool()(self.update_script)
|
||||
self.app.tool()(self.update_script_with_code_reference)
|
||||
self.app.tool()(self.expire_and_get_info)
|
||||
|
||||
def is_server_available(self, ctx: Context) -> bool:
|
||||
"""Grasshopper: Check if the Grasshopper server is available.
|
||||
|
||||
This is a quick check to see if the Grasshopper socket server is running
|
||||
and available for connections.
|
||||
|
||||
Returns:
|
||||
bool: True if the server is available, False otherwise
|
||||
"""
|
||||
try:
|
||||
connection = get_grasshopper_connection()
|
||||
return connection.check_server_available()
|
||||
except Exception as e:
|
||||
logger.error("Error checking Grasshopper server availability: {0}".format(str(e)))
|
||||
return False
|
||||
|
||||
def execute_code_in_gh(self, ctx: Context, code: str) -> str:
|
||||
"""Grasshopper: Execute arbitrary Python code in Grasshopper.
|
||||
|
||||
IMPORTANT:
|
||||
- Uses IronPython 2.7 - no f-strings or modern Python features
|
||||
- Always include ALL required imports in your code
|
||||
- Use 'result = value' to return data (don't use return statements)
|
||||
|
||||
Example - Adding components to canvas:
|
||||
```python
|
||||
import scriptcontext as sc
|
||||
import clr
|
||||
import Rhino
|
||||
import System.Drawing as sd
|
||||
import Grasshopper
|
||||
import Grasshopper.Kernel.Special as GHSpecial
|
||||
|
||||
doc = ghenv.Component.OnPingDocument()
|
||||
|
||||
# Create and position a Pipeline
|
||||
pipe = GHSpecial.GH_GeometryPipeline()
|
||||
if pipe.Attributes is None: pipe.CreateAttributes()
|
||||
pipe.Attributes.Pivot = sd.PointF(100, 100)
|
||||
doc.AddObject(pipe, False)
|
||||
|
||||
# Create and connect a Panel
|
||||
pan = GHSpecial.GH_Panel()
|
||||
if pan.Attributes is None: pan.CreateAttributes()
|
||||
pan.Attributes.Pivot = sd.PointF(300, 100)
|
||||
doc.AddObject(pan, False)
|
||||
pan.AddSource(pipe)
|
||||
|
||||
result = "Components created successfully"
|
||||
```
|
||||
|
||||
You can also provide the code as part of a JSON object with a "code" field.
|
||||
|
||||
Args:
|
||||
code: The Python code to execute, or a JSON object with a "code" field
|
||||
|
||||
Returns:
|
||||
The result of the code execution
|
||||
"""
|
||||
try:
|
||||
# Check if the input might be a JSON payload
|
||||
if isinstance(code, str) and (
|
||||
code.strip().startswith('{') or
|
||||
code.strip().startswith('`{') or
|
||||
'`code`' in code or
|
||||
'"code"' in code
|
||||
):
|
||||
# Try direct extraction for speed and reliability
|
||||
payload = extract_payload_fields(code)
|
||||
if payload and "code" in payload:
|
||||
code = payload["code"]
|
||||
|
||||
# Validate that we have code to execute
|
||||
if not code or not isinstance(code, str):
|
||||
return "Error: No valid code provided. Please provide Python code to execute."
|
||||
|
||||
# Make sure code ends with a result variable if it doesn't have one
|
||||
if "result =" not in code and "result=" not in code:
|
||||
# Extract the last line if it starts with "return"
|
||||
lines = code.strip().split("\n")
|
||||
if lines and lines[-1].strip().startswith("return "):
|
||||
return_value = lines[-1].strip()[7:].strip() # Remove "return " prefix
|
||||
# Replace the return with a result assignment
|
||||
lines[-1] = "result = " + return_value
|
||||
code = "\n".join(lines)
|
||||
else:
|
||||
# Append a default result if no return or result is present
|
||||
code += "\n\n# Auto-added result assignment\nresult = \"Code executed successfully\""
|
||||
|
||||
logger.info(f"Sending code execution request to Grasshopper")
|
||||
connection = get_grasshopper_connection()
|
||||
|
||||
result = connection.send_command("execute_code", {
|
||||
"code": code
|
||||
})
|
||||
|
||||
# Simply return result info with error prefix if needed
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
return result.get("result", "Code executed successfully")
|
||||
|
||||
except Exception as e:
|
||||
return f"Error executing code: {str(e)}"
|
||||
|
||||
def get_gh_context(self, ctx: Context, simplified: bool = False) -> str:
|
||||
"""Grasshopper: Get current Grasshopper document state and definition graph, sorted by execution order.
|
||||
|
||||
Returns a JSON string containing:
|
||||
- Component graph (connections between components)
|
||||
- Component info (guid, name, type)
|
||||
- Component properties and parameters
|
||||
|
||||
Args:
|
||||
simplified: When true, returns minimal component info without detailed properties
|
||||
|
||||
Returns:
|
||||
JSON string with grasshopper definition graph
|
||||
"""
|
||||
try:
|
||||
logger.info("Getting Grasshopper context with simplified={0}".format(simplified))
|
||||
connection = get_grasshopper_connection()
|
||||
result = connection.send_command("get_context", {
|
||||
"simplified": simplified
|
||||
})
|
||||
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
return json.dumps(result.get("result", {}), indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting context: {str(e)}"
|
||||
|
||||
def get_objects(self, ctx: Context, instance_guids: List[str], simplified: bool = False, context_depth: int = 0) -> str:
|
||||
"""Grasshopper: Get information about specific components by their GUIDs.
|
||||
|
||||
Args:
|
||||
instance_guids: List of component GUIDs to retrieve
|
||||
simplified: When true, returns minimal component info
|
||||
context_depth: How many levels of connected components to include (0-3), try to keep it small
|
||||
|
||||
Returns:
|
||||
JSON string with component information and optional context
|
||||
"""
|
||||
try:
|
||||
logger.info("Getting objects with GUIDs: {0}".format(instance_guids))
|
||||
connection = get_grasshopper_connection()
|
||||
result = connection.send_command("get_objects", {
|
||||
"instance_guids": instance_guids,
|
||||
"simplified": simplified,
|
||||
"context_depth": context_depth
|
||||
})
|
||||
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
return json.dumps(result.get("result", {}), indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting objects: {str(e)}"
|
||||
|
||||
def get_selected(self, ctx: Context, simplified: bool = False, context_depth: int = 0) -> str:
|
||||
"""Grasshopper: Get information about currently selected components.
|
||||
|
||||
Args:
|
||||
simplified: When true, returns minimal component info
|
||||
context_depth: How many levels of connected components to include (0-3)
|
||||
|
||||
Returns:
|
||||
JSON string with selected component information and optional context
|
||||
"""
|
||||
try:
|
||||
logger.info("Getting selected components")
|
||||
connection = get_grasshopper_connection()
|
||||
result = connection.send_command("get_selected", {
|
||||
"simplified": simplified,
|
||||
"context_depth": context_depth
|
||||
})
|
||||
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
return json.dumps(result.get("result", {}), indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error getting selected components: {str(e)}"
|
||||
|
||||
def update_script(self, ctx: Context, instance_guid: str = None, code: str = None, description: str = None,
|
||||
message_to_user: str = None, param_definitions: List[Dict[str, Any]] = None) -> str:
|
||||
"""Grasshopper: Update a script component with new code, description, user feedback message, and optionally redefine its parameters.
|
||||
|
||||
IMPORTANT NOTES:
|
||||
0. the output param "output" is reserved for the "message_to_user", name output params with a meaningful name if you create new ones
|
||||
1. The code must be valid Python 2.7 / IronPython code (no f-strings!)
|
||||
2. When updating existing code:
|
||||
- If NOT changing parameters, ensure to keep the same input/output variable names!
|
||||
- Know their datatypes and access methods (list, datatree, item) before modifying
|
||||
- The script may be part of a larger definition - maintain input/output structure
|
||||
3. When changing input and outputparameters:
|
||||
- You must provide ALL input/output parameters, even existing ones you want to keep
|
||||
- The component will be completely reconfigured with the new parameter set
|
||||
- Existing connections may be lost if parameter names change
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"instance_guid": "a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a",
|
||||
"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",
|
||||
"description": "Creates a circle and outputs its geometry, center point, and area",
|
||||
"message_to_user": "Circle component updated with new outputs for center point and area calculation",
|
||||
"param_definitions": [
|
||||
{
|
||||
"type": "input",
|
||||
"name": "radius",
|
||||
"access": "item",
|
||||
"typehint": "float",
|
||||
"description": "Circle radius",
|
||||
"optional": false,
|
||||
"default": 1.0
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"name": "circle",
|
||||
"description": "Generated circle geometry"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"name": "center",
|
||||
"description": "Center point of the circle"
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"name": "output",
|
||||
"description": "Used to display messages to the user"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
instance_guid: The GUID of the script component to update
|
||||
code: Optional new Python code for the component
|
||||
description: Optional new description for the component
|
||||
message_to_user: Optional feedback message that should include a change summary and/or suggestions
|
||||
param_definitions: Optional list of parameter definitions. If provided, ALL parameters will be redefined.
|
||||
Each definition must be a dictionary with:
|
||||
Required keys:
|
||||
- "type": "input" or "output"
|
||||
- "name": Parameter name (string)
|
||||
Optional keys for inputs:
|
||||
- "access": "item", "list", or "tree" (default "list")
|
||||
- "typehint": e.g. "str", "int", "float", "bool" (determines parameter type)
|
||||
- "description": Parameter description
|
||||
- "optional": bool, default True
|
||||
- "default": Default value (not persistent)
|
||||
|
||||
Returns:
|
||||
Success status with summary of which elements were updated
|
||||
"""
|
||||
try:
|
||||
# Log initial input for debugging
|
||||
if isinstance(instance_guid, str) and len(instance_guid) > 200:
|
||||
logger.info(f"Received long payload as instance_guid parameter: first 100 chars: {instance_guid[:100]}...")
|
||||
else:
|
||||
logger.info(f"Initial parameters: instance_guid={instance_guid}, code length={len(code) if code else 0}, "
|
||||
f"description={'provided' if description else 'None'}, "
|
||||
f"message_to_user={'provided' if message_to_user else 'None'}, "
|
||||
f"param_definitions={'provided' if param_definitions else 'None'}")
|
||||
|
||||
# Check if the first argument is a string that looks like a JSON payload
|
||||
if isinstance(instance_guid, str) and (
|
||||
instance_guid.strip().startswith('{') or
|
||||
instance_guid.strip().startswith('`{') or
|
||||
'`instance_guid`' in instance_guid or
|
||||
'"instance_guid"' in instance_guid
|
||||
):
|
||||
logger.info("Detected JSON-like payload in instance_guid parameter, extracting fields")
|
||||
# More robust extraction for complex payloads
|
||||
payload = extract_payload_fields(instance_guid)
|
||||
if payload and "instance_guid" in payload:
|
||||
# Log what was extracted
|
||||
logger.info(f"Extracted fields from payload: {sorted(payload.keys())}")
|
||||
|
||||
instance_guid = payload.get("instance_guid")
|
||||
code = payload.get("code", code)
|
||||
description = payload.get("description", description)
|
||||
message_to_user = payload.get("message_to_user", message_to_user)
|
||||
param_definitions = payload.get("param_definitions", param_definitions)
|
||||
|
||||
logger.info(f"After extraction: instance_guid={instance_guid}, code length={len(code) if code else 0}")
|
||||
else:
|
||||
logger.warning("Failed to extract instance_guid from payload")
|
||||
|
||||
# Ensure we have a valid instance_guid
|
||||
if not instance_guid:
|
||||
logger.error("No instance_guid provided")
|
||||
return "Error: No instance_guid provided. Please specify the GUID of the script component to update."
|
||||
|
||||
logger.info(f"Updating script component {instance_guid}")
|
||||
logger.info(f"Parameter details: code={bool(code)}, description={bool(description)}, "
|
||||
f"message_to_user={bool(message_to_user)}, param_definitions type={type(param_definitions) if param_definitions else None}")
|
||||
|
||||
connection = get_grasshopper_connection()
|
||||
|
||||
# Sanitize param_definitions if provided
|
||||
if param_definitions is not None and isinstance(param_definitions, list):
|
||||
# Create new sanitized list
|
||||
sanitized_params = []
|
||||
for param in param_definitions:
|
||||
if isinstance(param, dict):
|
||||
sanitized_params.append(param.copy())
|
||||
else:
|
||||
# Try to parse if it's a string
|
||||
try:
|
||||
if isinstance(param, str):
|
||||
param_dict = json.loads(preprocess_llm_input(param))
|
||||
sanitized_params.append(param_dict)
|
||||
except:
|
||||
logger.warning(f"Could not parse parameter definition: {param}")
|
||||
|
||||
param_definitions = sanitized_params
|
||||
|
||||
# Prepare the command payload - log it before sending
|
||||
command_payload = {
|
||||
"instance_guid": instance_guid,
|
||||
"code": code,
|
||||
"description": description,
|
||||
"message_to_user": message_to_user,
|
||||
"param_definitions": param_definitions
|
||||
}
|
||||
|
||||
logger.info(f"Sending command with payload keys: {sorted(command_payload.keys())}")
|
||||
if code:
|
||||
logger.info(f"Code snippet (first 50 chars): {code[:50]}...")
|
||||
|
||||
# Always use "update_script" as the command type
|
||||
result = connection.send_command("update_script", command_payload)
|
||||
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
return json.dumps(result.get("result", {}), indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error updating script: {str(e)}"
|
||||
|
||||
def update_script_with_code_reference(self, ctx: Context, instance_guid: str = None, file_path: str = None,
|
||||
param_definitions: List[Dict[str, Any]] = None, description: str = None,
|
||||
name: str = None, force_code_reference: bool = False) -> str:
|
||||
"""Grasshopper: Update a script component to use code from an external Python file.
|
||||
This tool allows you to modify a GHPython script component to use code from an external Python file
|
||||
instead of embedded code. This enables better code organization, version control, and reuse across
|
||||
multiple components. Moreove, you can add and remove input/ output paramters.
|
||||
|
||||
important notes:
|
||||
1. Only use when working in/with curser or another IDE
|
||||
2. First, check the grasshopper script component using "get_objects" tool
|
||||
3. Second, check if a python file is already referenced by the component AND if it exists in the cursor project
|
||||
ALWAYS add the component instance_guid to the file name (e.g. cirler_creator_a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a.py)
|
||||
4. write code im the file and save it, update the file path with this tool
|
||||
5. Once referenced, future updates on the code file will automatically be reflected in the component (no need to use this tool)
|
||||
6. you can use get_objects tool to get potential error messages from the component for debugging (runtimeMessages)
|
||||
|
||||
Args:
|
||||
instance_guid: The GUID of the target GHPython component to modify.
|
||||
file_path: Path to the external Python file that contains the code.
|
||||
param_definitions: List of dictionaries defining input/output parameters.
|
||||
description: New description for the component.
|
||||
name: New nickname for the component.
|
||||
force_code_reference: When True, converts/sets a component to use referenced code mode.
|
||||
|
||||
Returns:
|
||||
Success status with summary of which elements were updated and component instance_guid
|
||||
|
||||
Example:
|
||||
```json
|
||||
{
|
||||
"instance_guid": "a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a",
|
||||
"file_path": "/scripts/cirler_creator_a1b2c3d4-e5f6-4a5b-9c8d-7e6f5d4c3b2a.py"
|
||||
"name":"CircleTool"
|
||||
"description": "Creates a circle and outputs its geometry, center point, and area",
|
||||
"message_to_user": "Circle, add one radius slider as input",
|
||||
force_code_reference = True,
|
||||
"param_definitions": [
|
||||
{
|
||||
"type": "input",
|
||||
"name": "radius",
|
||||
"access": "item",
|
||||
"typehint": "float",
|
||||
"description": "Circle radius",
|
||||
"optional": false,
|
||||
"default": 1.0
|
||||
},
|
||||
{
|
||||
"type": "output",
|
||||
"name": "circle",
|
||||
"description": "Generated circle geometry"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
"""
|
||||
try:
|
||||
# Ensure we have a valid instance_guid
|
||||
if not instance_guid:
|
||||
return "Error: No instance_guid provided. Please specify the GUID of the script component to update."
|
||||
|
||||
connection = get_grasshopper_connection()
|
||||
|
||||
# Prepare the command payload
|
||||
command_payload = {
|
||||
"instance_guid": instance_guid,
|
||||
"file_path": file_path,
|
||||
"param_definitions": param_definitions,
|
||||
"description": description,
|
||||
"name": name,
|
||||
"force_code_reference": force_code_reference
|
||||
}
|
||||
|
||||
# Send command and get result
|
||||
result = connection.send_command("update_script_with_code_reference", command_payload)
|
||||
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
return json.dumps(result.get("result", {}), indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error updating script with code reference: {str(e)}"
|
||||
|
||||
def expire_and_get_info(self, ctx: Context, instance_guid: str) -> str:
|
||||
"""Grasshopper: Expire a specific component and get its updated information.
|
||||
|
||||
This is useful after updating a component's code, especially via a referenced file,
|
||||
to force a recompute and retrieve the latest state, including potential errors or messages.
|
||||
|
||||
Args:
|
||||
instance_guid: The GUID of the component to expire and query.
|
||||
|
||||
Returns:
|
||||
JSON string with the component's updated information after expiration.
|
||||
"""
|
||||
try:
|
||||
if not instance_guid:
|
||||
return "Error: No instance_guid provided. Please specify the GUID of the component to expire."
|
||||
|
||||
logger.info(f"Expiring component and getting info for GUID: {instance_guid}")
|
||||
connection = get_grasshopper_connection()
|
||||
result = connection.send_command("expire_component", {
|
||||
"instance_guid": instance_guid
|
||||
})
|
||||
|
||||
if result.get("status") == "error":
|
||||
return f"Error: {result.get('result', 'Unknown error')}"
|
||||
# The server side already returns component info after expiring
|
||||
return json.dumps(result.get("result", {}), indent=2)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error expiring component: {str(e)}"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tools for interacting with Replicate's Flux Depth model."""
|
||||
import os
|
||||
import requests
|
||||
import base64
|
||||
import io
|
||||
import time
|
||||
import logging
|
||||
from mcp.server.fastmcp import Context, Image
|
||||
from PIL import Image as PILImage
|
||||
|
||||
logger = logging.getLogger("ReplicateTools")
|
||||
|
||||
class ReplicateTools:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self.api_token = os.environ.get('REPLICATE_API_TOKEN') or os.environ.get('REPLICATE_TOKEN')
|
||||
if not self.api_token:
|
||||
logger.warning("No Replicate API token found in environment")
|
||||
self.app.tool()(self.render_rhino_scene)
|
||||
|
||||
def render_rhino_scene(self, ctx: Context, prompt: str) -> Image:
|
||||
"""Transform Rhino viewport with AI using the given prompt, ensure to display the result image in chat afterwads"
|
||||
|
||||
Args:
|
||||
prompt: A prompt to guide the rendering of the scene
|
||||
|
||||
Returns:
|
||||
An MCP Image object containing the rendered scene
|
||||
"""
|
||||
try:
|
||||
# Get Rhino viewport image
|
||||
from .rhino_tools import get_rhino_connection
|
||||
connection = get_rhino_connection()
|
||||
result = connection.send_command("capture_viewport", {
|
||||
"layer": None,
|
||||
"show_annotations": False,
|
||||
"max_size": 800
|
||||
})
|
||||
|
||||
if result.get("type") != "image":
|
||||
return "Error: Failed to capture viewport"
|
||||
|
||||
# Get base64 data and prepare request
|
||||
base64_data = result["source"]["data"]
|
||||
headers = {
|
||||
"Authorization": f"Token {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
# Start prediction
|
||||
response = requests.post(
|
||||
"https://api.replicate.com/v1/predictions",
|
||||
json={
|
||||
"version": "black-forest-labs/flux-depth-dev",
|
||||
"input": {
|
||||
"prompt": prompt,
|
||||
"control_image": f"data:image/jpeg;base64,{base64_data}"
|
||||
}
|
||||
},
|
||||
headers=headers
|
||||
)
|
||||
prediction = response.json()
|
||||
prediction_url = prediction["urls"]["get"]
|
||||
|
||||
# Poll until complete (max 60 seconds)
|
||||
for _ in range(30):
|
||||
time.sleep(2)
|
||||
response = requests.get(prediction_url, headers=headers)
|
||||
prediction = response.json()
|
||||
|
||||
# Check if complete
|
||||
if prediction["status"] == "succeeded" and prediction.get("output"):
|
||||
# Get image URL and download it
|
||||
image_url = prediction["output"]
|
||||
if isinstance(image_url, list):
|
||||
image_url = image_url[0]
|
||||
|
||||
# Download and convert to MCP Image
|
||||
image_data = requests.get(image_url).content
|
||||
img = PILImage.open(io.BytesIO(image_data))
|
||||
|
||||
# Resize to max 800px while maintaining aspect ratio
|
||||
max_size = 800
|
||||
if img.width > max_size or img.height > max_size:
|
||||
ratio = max_size / max(img.width, img.height)
|
||||
new_size = (int(img.width * ratio), int(img.height * ratio))
|
||||
img = img.resize(new_size, PILImage.Resampling.LANCZOS)
|
||||
|
||||
# Save with controlled quality
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=70, optimize=True)
|
||||
return Image(data=buffer.getvalue(), format="jpeg")
|
||||
|
||||
# Check for errors
|
||||
if prediction["status"] not in ["processing", "starting"]:
|
||||
return f"Error: Model failed with status {prediction['status']}"
|
||||
|
||||
return "Error: Prediction timed out"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Tools for interacting with Rhino through socket connection."""
|
||||
from mcp.server.fastmcp import FastMCP, Context, Image
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Dict, Any, List, Optional
|
||||
import json
|
||||
import socket
|
||||
import time
|
||||
import base64
|
||||
import io
|
||||
from PIL import Image as PILImage
|
||||
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger("RhinoTools")
|
||||
|
||||
class RhinoConnection:
|
||||
def __init__(self, host='localhost', port=9876):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.socket = None
|
||||
self.timeout = 30.0 # 30 second timeout
|
||||
self.buffer_size = 14485760 # 10MB buffer size for handling large images
|
||||
|
||||
def connect(self):
|
||||
"""Connect to the Rhino script's socket server"""
|
||||
if self.socket is None:
|
||||
try:
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.socket.settimeout(self.timeout)
|
||||
self.socket.connect((self.host, self.port))
|
||||
logger.info("Connected to Rhino script")
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to Rhino script: {0}".format(str(e)))
|
||||
self.disconnect()
|
||||
raise
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from the Rhino script"""
|
||||
if self.socket:
|
||||
try:
|
||||
self.socket.close()
|
||||
except:
|
||||
pass
|
||||
self.socket = None
|
||||
|
||||
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Send a command to the Rhino script and wait for response"""
|
||||
if self.socket is None:
|
||||
self.connect()
|
||||
|
||||
try:
|
||||
# Prepare command
|
||||
command = {
|
||||
"type": command_type,
|
||||
"params": params or {}
|
||||
}
|
||||
|
||||
# Send command
|
||||
command_json = json.dumps(command)
|
||||
logger.info("Sending command: {0}".format(command_json))
|
||||
self.socket.sendall(command_json.encode('utf-8'))
|
||||
|
||||
# Receive response with timeout and larger buffer
|
||||
buffer = b''
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check timeout
|
||||
if time.time() - start_time > self.timeout:
|
||||
raise Exception("Response timeout after {0} seconds".format(self.timeout))
|
||||
|
||||
# Receive data
|
||||
data = self.socket.recv(self.buffer_size)
|
||||
if not data:
|
||||
break
|
||||
|
||||
buffer += data
|
||||
logger.debug("Received {0} bytes of data".format(len(data)))
|
||||
|
||||
# Try to parse JSON
|
||||
try:
|
||||
response = json.loads(buffer.decode('utf-8'))
|
||||
logger.info("Received complete response: {0}".format(response))
|
||||
|
||||
# Check for error response
|
||||
if response.get("status") == "error":
|
||||
raise Exception(response.get("message", "Unknown error from Rhino"))
|
||||
|
||||
return response
|
||||
except json.JSONDecodeError:
|
||||
# If we have a complete response, it should be valid JSON
|
||||
if len(buffer) > 0:
|
||||
continue
|
||||
else:
|
||||
raise Exception("Invalid JSON response from Rhino")
|
||||
|
||||
except socket.timeout:
|
||||
raise Exception("Socket timeout while receiving response")
|
||||
|
||||
raise Exception("Connection closed by Rhino script")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error communicating with Rhino script: {0}".format(str(e)))
|
||||
self.disconnect() # Disconnect on error to force reconnection
|
||||
raise
|
||||
|
||||
# Global connection instance
|
||||
_rhino_connection = None
|
||||
|
||||
def get_rhino_connection() -> RhinoConnection:
|
||||
"""Get or create the Rhino connection"""
|
||||
global _rhino_connection
|
||||
if _rhino_connection is None:
|
||||
_rhino_connection = RhinoConnection()
|
||||
return _rhino_connection
|
||||
|
||||
class RhinoTools:
|
||||
"""Collection of tools for interacting with Rhino."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self._register_tools()
|
||||
|
||||
def _register_tools(self):
|
||||
"""Register all Rhino tools with the MCP server."""
|
||||
self.app.tool()(self.get_scene_info)
|
||||
self.app.tool()(self.get_layers)
|
||||
self.app.tool()(self.get_scene_objects_with_metadata)
|
||||
self.app.tool()(self.capture_viewport)
|
||||
self.app.tool()(self.execute_rhino_code)
|
||||
|
||||
def get_scene_info(self, ctx: Context) -> str:
|
||||
"""Get basic information about the current Rhino scene.
|
||||
|
||||
This is a lightweight function that returns basic scene information:
|
||||
- List of all layers with basic information about the layer and 5 sample objects with their metadata
|
||||
- No metadata or detailed properties
|
||||
- Use this for quick scene overview or when you only need basic object information
|
||||
|
||||
Returns:
|
||||
JSON string containing basic scene information
|
||||
"""
|
||||
try:
|
||||
connection = get_rhino_connection()
|
||||
result = connection.send_command("get_scene_info")
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
logger.error("Error getting scene info from Rhino: {0}".format(str(e)))
|
||||
return "Error getting scene info: {0}".format(str(e))
|
||||
|
||||
def get_layers(self, ctx: Context) -> str:
|
||||
"""Get list of layers in Rhino"""
|
||||
try:
|
||||
connection = get_rhino_connection()
|
||||
result = connection.send_command("get_layers")
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
logger.error("Error getting layers from Rhino: {0}".format(str(e)))
|
||||
return "Error getting layers: {0}".format(str(e))
|
||||
|
||||
def get_scene_objects_with_metadata(self, ctx: Context, filters: Optional[Dict[str, Any]] = None, metadata_fields: Optional[List[str]] = None) -> str:
|
||||
"""Get detailed information about objects in the scene with their metadata.
|
||||
|
||||
This is a CORE FUNCTION for scene context awareness. It provides:
|
||||
1. Full metadata for each object we created via this mcp connection including:
|
||||
- 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
|
||||
- created_at timestamp
|
||||
- layer - layer path
|
||||
- type - geometry type
|
||||
- bbox - the bounding box as lsit of points
|
||||
- name - the name you assigned
|
||||
- description - description yo uasigned
|
||||
|
||||
2. Advanced filtering capabilities:
|
||||
- layer: Filter by layer name (supports wildcards, e.g., "Layer*")
|
||||
- name: Filter by object name (supports wildcards, e.g., "Cube*")
|
||||
- short_id: Filter by exact short ID match
|
||||
|
||||
3. Field selection:
|
||||
- Can specify which metadata fields to return
|
||||
- Useful for reducing response size when only certain fields are needed
|
||||
|
||||
Args:
|
||||
filters: Optional dictionary of filters to apply
|
||||
metadata_fields: Optional list of specific metadata fields to return
|
||||
|
||||
Returns:
|
||||
JSON string containing filtered objects with their metadata
|
||||
"""
|
||||
try:
|
||||
connection = get_rhino_connection()
|
||||
result = connection.send_command("get_objects_with_metadata", {
|
||||
"filters": filters or {},
|
||||
"metadata_fields": metadata_fields
|
||||
})
|
||||
return json.dumps(result, indent=2)
|
||||
except Exception as e:
|
||||
logger.error("Error getting objects with metadata: {0}".format(str(e)))
|
||||
return "Error getting objects with metadata: {0}".format(str(e))
|
||||
|
||||
def capture_viewport(self, ctx: Context, layer: Optional[str] = None, show_annotations: bool = True, max_size: int = 800) -> Image:
|
||||
"""Capture the current viewport as an image.
|
||||
|
||||
Args:
|
||||
layer: Optional layer name to filter annotations
|
||||
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
|
||||
|
||||
Returns:
|
||||
An MCP Image object containing the viewport capture
|
||||
"""
|
||||
try:
|
||||
connection = get_rhino_connection()
|
||||
result = connection.send_command("capture_viewport", {
|
||||
"layer": layer,
|
||||
"show_annotations": show_annotations,
|
||||
"max_size": max_size
|
||||
})
|
||||
|
||||
if result.get("type") == "image":
|
||||
# Get base64 data from Rhino
|
||||
base64_data = result["source"]["data"]
|
||||
|
||||
# Convert base64 to bytes
|
||||
image_bytes = base64.b64decode(base64_data)
|
||||
|
||||
# Create PIL Image from bytes
|
||||
img = PILImage.open(io.BytesIO(image_bytes))
|
||||
|
||||
# Convert to PNG format for better quality and consistency
|
||||
png_buffer = io.BytesIO()
|
||||
img.save(png_buffer, format="PNG")
|
||||
png_bytes = png_buffer.getvalue()
|
||||
|
||||
# Return as MCP Image object
|
||||
return Image(data=png_bytes, format="png")
|
||||
|
||||
else:
|
||||
raise Exception(result.get("text", "Failed to capture viewport"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error capturing viewport: {0}".format(str(e)))
|
||||
raise
|
||||
|
||||
def execute_rhino_code(self, ctx: Context, code: str) -> str:
|
||||
"""Execute arbitrary Python code in Rhino.
|
||||
|
||||
IMPORTANT NOTES FOR CODE EXECUTION:
|
||||
0. DONT FORGET NO f-strings! No f-strings, No f-strings!
|
||||
1. This is Rhino 7 with IronPython 2.7 - no f-strings or modern Python features
|
||||
3. When creating objects, ALWAYS call add_object_metadata(name, description) after creation
|
||||
4. For user interaction, you can use RhinoCommon syntax (selected_objects = rs.GetObjects("Please select some objects") etc.) prompted the suer what to do
|
||||
but prefer automated solutions unless user interaction is specifically requested
|
||||
|
||||
The add_object_metadata() function is provided in the code context and must be called
|
||||
after creating any object. It adds standardized metadata including:
|
||||
- name (provided by you)
|
||||
- description (provided by you)
|
||||
The metadata helps you to identify and select objects later in the scene and stay organised.
|
||||
|
||||
Common Syntax Errors to Avoid:
|
||||
2. No walrus operator (:=)
|
||||
3. No type hints
|
||||
4. No modern Python features (match/case, etc.)
|
||||
5. No list/dict comprehensions with multiple for clauses
|
||||
6. No assignment expressions in if/while conditions
|
||||
|
||||
Example of proper object creation:
|
||||
<<<python
|
||||
# Create geometry
|
||||
cube_id = rs.AddBox(rs.WorldXYPlane(), 5, 5, 5)
|
||||
# Add metadata - ALWAYS do this after creating an object
|
||||
add_object_metadata(cube_id, "My Cube", "A test cube created via MCP")
|
||||
|
||||
>>>
|
||||
|
||||
DONT FORGET NO f-strings! No f-strings, No f-strings!
|
||||
"""
|
||||
try:
|
||||
code_template = """
|
||||
import rhinoscriptsyntax as rs
|
||||
import scriptcontext as sc
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
def add_object_metadata(obj_id, name=None, description=None):
|
||||
\"\"\"Add standardized metadata to an object\"\"\"
|
||||
try:
|
||||
# Generate short ID
|
||||
short_id = datetime.now().strftime("%d%H%M%S")
|
||||
|
||||
# Get bounding box
|
||||
bbox = rs.BoundingBox(obj_id)
|
||||
bbox_data = [[p.X, p.Y, p.Z] for p in bbox] if bbox else []
|
||||
|
||||
# Get object type
|
||||
obj = sc.doc.Objects.Find(obj_id)
|
||||
obj_type = obj.Geometry.GetType().Name if obj else "Unknown"
|
||||
|
||||
# Standard metadata
|
||||
metadata = {
|
||||
"short_id": short_id,
|
||||
"created_at": time.time(),
|
||||
"layer": rs.ObjectLayer(obj_id),
|
||||
"type": obj_type,
|
||||
"bbox": bbox_data
|
||||
}
|
||||
|
||||
# User-provided metadata
|
||||
if name:
|
||||
rs.ObjectName(obj_id, name)
|
||||
metadata["name"] = name
|
||||
else:
|
||||
auto_name = "{0}_{1}".format(obj_type, short_id)
|
||||
rs.ObjectName(obj_id, auto_name)
|
||||
metadata["name"] = auto_name
|
||||
|
||||
if description:
|
||||
metadata["description"] = description
|
||||
|
||||
# Store metadata as user text
|
||||
user_text_data = metadata.copy()
|
||||
user_text_data["bbox"] = json.dumps(bbox_data)
|
||||
|
||||
for key, value in user_text_data.items():
|
||||
rs.SetUserText(obj_id, key, str(value))
|
||||
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
""" + code
|
||||
logger.info("Sending code execution request to Rhino")
|
||||
connection = get_rhino_connection()
|
||||
result = connection.send_command("execute_code", {"code": code_template})
|
||||
|
||||
logger.info("Received response from Rhino: {0}".format(result))
|
||||
|
||||
# Simplified error handling
|
||||
if result.get("status") == "error":
|
||||
error_msg = "Error: {0}".format(result.get("message", "Unknown error"))
|
||||
logger.error("Code execution error: {0}".format(error_msg))
|
||||
return error_msg
|
||||
else:
|
||||
response = result.get("result", "Code executed successfully")
|
||||
logger.info("Code execution successful: {0}".format(response))
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
error_msg = "Error executing code: {0}".format(str(e))
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Rhino integration through the Model Context Protocol."""
|
||||
from mcp.server.fastmcp import FastMCP, Context, Image
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncIterator, Dict, Any, List, Optional
|
||||
import json
|
||||
import io
|
||||
from PIL import Image as PILImage
|
||||
from pathlib import Path
|
||||
|
||||
# Try to load environment variables from .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
env_path = Path(__file__).parent.parent / '.env'
|
||||
if env_path.exists():
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
logging.info(f"Loaded environment variables from {env_path}")
|
||||
except ImportError:
|
||||
logging.warning("python-dotenv not installed. Install it to use .env files: pip install python-dotenv")
|
||||
|
||||
# Import our tool modules
|
||||
from .replicate_tools import ReplicateTools
|
||||
from .rhino_tools import RhinoTools, get_rhino_connection
|
||||
from .grasshopper_tools import GrasshopperTools, get_grasshopper_connection
|
||||
from .utility_tools import UtilityTools
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger("RhinoMCPServer")
|
||||
|
||||
@asynccontextmanager
|
||||
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Manage server startup and shutdown lifecycle"""
|
||||
rhino_conn = None
|
||||
gh_conn = None
|
||||
|
||||
try:
|
||||
logger.info("RhinoMCP server starting up")
|
||||
|
||||
# Try to connect to Rhino script
|
||||
try:
|
||||
rhino_conn = get_rhino_connection()
|
||||
rhino_conn.connect()
|
||||
logger.info("Successfully connected to Rhino script")
|
||||
except Exception as e:
|
||||
logger.warning("Could not connect to Rhino script: {0}".format(str(e)))
|
||||
|
||||
# Try to connect to Grasshopper script
|
||||
try:
|
||||
gh_conn = get_grasshopper_connection()
|
||||
# Just check if the server is available - don't connect yet
|
||||
if gh_conn.check_server_available():
|
||||
logger.info("Grasshopper server is available")
|
||||
else:
|
||||
logger.warning("Grasshopper server is not available. Start the GHPython component in Grasshopper to enable Grasshopper integration.")
|
||||
except Exception as e:
|
||||
logger.warning("Error checking Grasshopper server availability: {0}".format(str(e)))
|
||||
|
||||
yield {}
|
||||
finally:
|
||||
logger.info("RhinoMCP server shut down")
|
||||
|
||||
# Clean up connections
|
||||
if rhino_conn:
|
||||
try:
|
||||
rhino_conn.disconnect()
|
||||
logger.info("Disconnected from Rhino script")
|
||||
except Exception as e:
|
||||
logger.warning("Error disconnecting from Rhino: {0}".format(str(e)))
|
||||
|
||||
if gh_conn:
|
||||
try:
|
||||
gh_conn.disconnect()
|
||||
logger.info("Disconnected from Grasshopper script")
|
||||
except Exception as e:
|
||||
logger.warning("Error disconnecting from Grasshopper: {0}".format(str(e)))
|
||||
|
||||
# Create the MCP server with lifespan support
|
||||
app = FastMCP(
|
||||
"RhinoMCP",
|
||||
lifespan=server_lifespan
|
||||
)
|
||||
|
||||
# Initialize tool collections
|
||||
replicate_tools = ReplicateTools(app)
|
||||
rhino_tools = RhinoTools(app)
|
||||
grasshopper_tools = GrasshopperTools(app)
|
||||
utility_tools = UtilityTools(app)
|
||||
|
||||
@app.prompt()
|
||||
def rhino_creation_strategy() -> str:
|
||||
"""Defines the preferred strategy for creating and managing objects in Rhino"""
|
||||
return """When working with Rhino through MCP, follow these guidelines:
|
||||
|
||||
Especially when working with geometry, iterate with smaller steps and check the scene state from time to time.
|
||||
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),
|
||||
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
|
||||
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)
|
||||
|
||||
1. Scene Context Awareness:
|
||||
- Always start by checking the scene using get_scene_info() for basic overview
|
||||
- Use the capture_viewport to get an image from viewport to get a quick overview of the scene
|
||||
- Use get_objects_with_metadata() for detailed object information and filtering
|
||||
- The short_id in metadata can be displayed in viewport using capture_viewport()
|
||||
|
||||
2. Object Creation and Management:
|
||||
- When creating objects, ALWAYS call add_object_metadata() after creation (The add_object_metadata() function is provided in the code context)
|
||||
- Use meaningful names for objects to help with you with later identification, organize the scenes with layers (but not too many layers)
|
||||
- Think about grouping objects (e.g. two planes that form a window)
|
||||
|
||||
3. Always check the bbox for each item so that (it's stored as list of points in the metadata under the key "bbox"):
|
||||
- Ensure that all objects that should not be clipping are not clipping.
|
||||
- Items have the right spatial relationship.
|
||||
|
||||
4. Code Execution:
|
||||
- This is Rhino 7 with IronPython 2.7 - no f-strings or modern Python features etc
|
||||
- DONT FORGET NO f-strings! No f-strings, No f-strings!
|
||||
- Prefer automated solutions over user interaction, unless its requested or it makes sense or you struggle with errors
|
||||
- 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
|
||||
|
||||
5. Best Practices:
|
||||
- Keep objects organized in appropriate layers
|
||||
- Use meaningful names and descriptions
|
||||
- Use viewport captures to verify visual results
|
||||
"""
|
||||
|
||||
@app.prompt()
|
||||
def grasshopper_usage_strategy() -> str:
|
||||
"""Defines the preferred strategy for working with Grasshopper through MCP"""
|
||||
return """When working with Grasshopper through MCP, follow these guidelines:
|
||||
|
||||
2. Document Exploration:
|
||||
- Use get_gh_context() to get an overview of all components and their connections
|
||||
- Use simplified=True for basic info, False for detailed properties (otherwise it get get very very long)
|
||||
- Use get_selected() to examine currently selected components
|
||||
- Use get_objects() with GUIDs to examine specific components
|
||||
- Use context_depth parameter (0-3) to include connected components in the response
|
||||
|
||||
3. Script Component Management:
|
||||
- Use update_script() to modify existing script components:
|
||||
- Only use proper IronPython 2.7 syntax (no f-strigns)
|
||||
- Add descriptions for better documentation
|
||||
- Include user messages to explain changes or suggest next steps
|
||||
- Define or modify component parameters:
|
||||
* When changing parameters, provide ALL desired parameters (even existing ones)
|
||||
* Specify input/output type, name, and optional properties
|
||||
* Consider access type (item/list/tree) for inputs
|
||||
* Be aware that changing parameters may affect existing connections
|
||||
|
||||
4. Code Execution Guidelines:
|
||||
- Always use IronPython 2.7 compatible code (no f-strings, walrus operator, etc.)
|
||||
- Include all required imports in your code
|
||||
- Use 'result = value' instead of 'return value' to return data as component output
|
||||
- You can create Grasshopper components via code
|
||||
- You can access Rhino objects by referencing them
|
||||
|
||||
5. Rhino Integration:
|
||||
- Grasshopper is closely integrated with Rhino
|
||||
- Grasshopper-generated geometry appears in Rhino viewport
|
||||
|
||||
6. Best Practices:
|
||||
- remember the Guid / Uuid of components you want to modify / need regular access to
|
||||
- work in small steps, break down the problem into smaller parts
|
||||
- Keep script components well-documented with clear descriptions
|
||||
- Use meaningful names for components and parameters
|
||||
- Test changes incrementally to ensure stability
|
||||
- When modifying scripts:
|
||||
* If not changing parameters, maintain existing input/output structure
|
||||
* If changing parameters, carefully plan the new parameter set
|
||||
* Document parameter changes in the user message
|
||||
"""
|
||||
|
||||
def main():
|
||||
"""Run the MCP server"""
|
||||
app.run(transport='stdio')
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Tools for web search and email functionality via n8n workflows."""
|
||||
from mcp.server.fastmcp import FastMCP, Context, Image
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, Union, List
|
||||
import json
|
||||
import requests
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import time
|
||||
from PIL import Image as PILImage
|
||||
import traceback
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger("UtilityTools")
|
||||
|
||||
class UtilityTools:
|
||||
"""Collection of utility tools that interface with n8n workflows."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
self._register_tools()
|
||||
|
||||
# Configuration for n8n webhooks
|
||||
self.web_search_webhook_url = "https://run8n.xyz/webhook/webSearchAgent"
|
||||
self.email_webhook_url = "https://run8n.xyz/webhook/gmailAgent"
|
||||
self.auth_token = "abc123secretvalue" # This should be in environment variables in production
|
||||
|
||||
def _register_tools(self):
|
||||
"""Register all utility tools with the MCP server."""
|
||||
self.app.tool()(self.web_search)
|
||||
self.app.tool()(self.email_tool)
|
||||
|
||||
def _generate_session_id(self):
|
||||
"""Generate a unique session ID."""
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S_") + str(int(time.time() * 1000))[-3:]
|
||||
|
||||
def _download_image(self, url, max_size=800, jpeg_quality=80):
|
||||
"""Download and process an image from URL."""
|
||||
try:
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
# Open image from bytes
|
||||
img = PILImage.open(BytesIO(response.content))
|
||||
|
||||
# Convert to RGB if necessary
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Calculate new dimensions while maintaining aspect ratio
|
||||
ratio = min(max_size / img.width, max_size / img.height)
|
||||
new_size = (int(img.width * ratio), int(img.height * ratio))
|
||||
|
||||
# Resize if needed
|
||||
if ratio < 1:
|
||||
img = img.resize(new_size, PILImage.Resampling.LANCZOS)
|
||||
|
||||
# Save as JPEG to BytesIO
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=jpeg_quality)
|
||||
|
||||
# Convert to base64
|
||||
image_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
|
||||
return {
|
||||
"data": image_base64,
|
||||
"format": "jpeg",
|
||||
"width": img.width,
|
||||
"height": img.height
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading/processing image from {url}: {str(e)}")
|
||||
return None
|
||||
|
||||
def _parse_search_response(self, response_data: Dict[str, Any], download_images: bool = False) -> Dict[str, Any]:
|
||||
"""Parse the search response from n8n webhook."""
|
||||
try:
|
||||
# Extract data from response
|
||||
output = json.loads(response_data.get("output", "{}"))
|
||||
|
||||
# Create result dictionary
|
||||
result = {
|
||||
"summary": output.get("shortSummary", "No summary available"),
|
||||
"report": output.get("searchResultReport", "No detailed report available"),
|
||||
"sources": output.get("sources", []),
|
||||
"image_urls": output.get("imageUrl", []),
|
||||
"images": [] # Will be populated if download_images is True
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing search response: {str(e)}")
|
||||
return {"status": "error", "message": f"Failed to parse response: {str(e)}"}
|
||||
|
||||
def web_search(self, ctx: Context, user_intent: str, download_images: bool = False) -> str:
|
||||
"""Perform a web search via n8n webhook.
|
||||
|
||||
Args:
|
||||
user_intent: The search query or intent
|
||||
download_images: Whether to download and process images
|
||||
|
||||
Returns:
|
||||
Search results in markdown format
|
||||
"""
|
||||
try:
|
||||
session_id = self._generate_session_id()
|
||||
|
||||
headers = {
|
||||
"Authorization": self.auth_token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"userIntent": user_intent,
|
||||
"downloadImages": download_images,
|
||||
"sessionId": session_id
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
self.web_search_webhook_url,
|
||||
headers=headers,
|
||||
json=payload
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# If images need to be downloaded
|
||||
if download_images and "imageUrls" in result:
|
||||
images = []
|
||||
for url in result["imageUrls"]:
|
||||
image_data = self._download_image(url)
|
||||
if image_data:
|
||||
images.append(image_data)
|
||||
result["images"] = images
|
||||
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error performing web search: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
def email_tool(self, ctx: Context, user_intent: str) -> str:
|
||||
"""Search and interact with emails via n8n webhook.
|
||||
|
||||
Args:
|
||||
user_intent: The email search query or intent
|
||||
|
||||
Returns:
|
||||
Email results in markdown format
|
||||
"""
|
||||
try:
|
||||
session_id = self._generate_session_id()
|
||||
|
||||
headers = {
|
||||
"Authorization": self.auth_token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"userIntent": user_intent,
|
||||
"sessionId": session_id
|
||||
}
|
||||
|
||||
response = requests.get(
|
||||
self.email_webhook_url,
|
||||
headers=headers,
|
||||
json=payload
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Return the markdown response directly
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error searching emails: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
@@ -0,0 +1,697 @@
|
||||
"""
|
||||
Rhino MCP - Rhino-side Script
|
||||
Handles communication with external MCP server and executes Rhino commands.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import threading
|
||||
import json
|
||||
import time
|
||||
import System
|
||||
import Rhino
|
||||
import scriptcontext as sc
|
||||
import rhinoscriptsyntax as rs
|
||||
import os
|
||||
import platform
|
||||
import traceback
|
||||
import sys
|
||||
import base64
|
||||
from System.Drawing import Bitmap
|
||||
from System.Drawing.Imaging import ImageFormat
|
||||
from System.IO import MemoryStream
|
||||
from datetime import datetime
|
||||
|
||||
# Configuration
|
||||
HOST = 'localhost'
|
||||
PORT = 9876
|
||||
|
||||
# Add constant for annotation layer
|
||||
ANNOTATION_LAYER = "MCP_Annotations"
|
||||
|
||||
VALID_METADATA_FIELDS = {
|
||||
'required': ['id', 'name', 'type', 'layer'],
|
||||
'optional': [
|
||||
'short_id', # Short identifier (DDHHMMSS format)
|
||||
'created_at', # Timestamp of creation
|
||||
'bbox', # Bounding box coordinates
|
||||
'description', # Object description
|
||||
'user_text' # All user text key-value pairs
|
||||
]
|
||||
}
|
||||
|
||||
def get_log_dir():
|
||||
"""Get the appropriate log directory based on the platform"""
|
||||
home_dir = os.path.expanduser("~")
|
||||
|
||||
# Platform-specific log directory
|
||||
if platform.system() == "Darwin": # macOS
|
||||
log_dir = os.path.join(home_dir, "Library", "Application Support", "RhinoMCP", "logs")
|
||||
elif platform.system() == "Windows":
|
||||
log_dir = os.path.join(home_dir, "AppData", "Local", "RhinoMCP", "logs")
|
||||
else: # Linux and others
|
||||
log_dir = os.path.join(home_dir, ".rhino_mcp", "logs")
|
||||
|
||||
return log_dir
|
||||
|
||||
def log_message(message):
|
||||
"""Log a message to both Rhino's command line and log file"""
|
||||
# Print to Rhino's command line
|
||||
Rhino.RhinoApp.WriteLine(message)
|
||||
|
||||
# Log to file
|
||||
try:
|
||||
log_dir = get_log_dir()
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
log_file = os.path.join(log_dir, "rhino_mcp.log")
|
||||
|
||||
# Log platform info on first run
|
||||
if not os.path.exists(log_file):
|
||||
with open(log_file, "w") as f:
|
||||
f.write("=== RhinoMCP Log ===\n")
|
||||
f.write("Platform: {0}\n".format(platform.system()))
|
||||
f.write("Python Version: {0}\n".format(sys.version))
|
||||
f.write("Rhino Version: {0}\n".format(Rhino.RhinoApp.Version))
|
||||
f.write("==================\n\n")
|
||||
|
||||
with open(log_file, "a") as f:
|
||||
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
f.write("[{0}] {1}\n".format(timestamp, message))
|
||||
except Exception as e:
|
||||
Rhino.RhinoApp.WriteLine("Failed to write to log file: {0}".format(str(e)))
|
||||
|
||||
class RhinoMCPServer:
|
||||
def __init__(self, host='localhost', port=9876):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.running = False
|
||||
self.socket = None
|
||||
self.server_thread = None
|
||||
|
||||
def start(self):
|
||||
if self.running:
|
||||
log_message("Server is already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
# Create socket
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self.socket.bind((self.host, self.port))
|
||||
self.socket.listen(1)
|
||||
|
||||
# Start server thread
|
||||
self.server_thread = threading.Thread(target=self._server_loop)
|
||||
self.server_thread.daemon = True
|
||||
self.server_thread.start()
|
||||
|
||||
log_message("RhinoMCP server started on {0}:{1}".format(self.host, self.port))
|
||||
except Exception as e:
|
||||
log_message("Failed to start server: {0}".format(str(e)))
|
||||
self.stop()
|
||||
|
||||
def stop(self):
|
||||
self.running = False
|
||||
|
||||
# Close socket
|
||||
if self.socket:
|
||||
try:
|
||||
self.socket.close()
|
||||
except:
|
||||
pass
|
||||
self.socket = None
|
||||
|
||||
# Wait for thread to finish
|
||||
if self.server_thread:
|
||||
try:
|
||||
if self.server_thread.is_alive():
|
||||
self.server_thread.join(timeout=1.0)
|
||||
except:
|
||||
pass
|
||||
self.server_thread = None
|
||||
|
||||
log_message("RhinoMCP server stopped")
|
||||
|
||||
def _server_loop(self):
|
||||
"""Main server loop that accepts connections"""
|
||||
while self.running:
|
||||
try:
|
||||
client, addr = self.socket.accept()
|
||||
log_message("Client connected from {0}:{1}".format(addr[0], addr[1]))
|
||||
|
||||
# Handle client in a new thread
|
||||
client_thread = threading.Thread(target=self._handle_client, args=(client,))
|
||||
client_thread.daemon = True
|
||||
client_thread.start()
|
||||
|
||||
except Exception as e:
|
||||
if self.running:
|
||||
log_message("Error accepting connection: {0}".format(str(e)))
|
||||
time.sleep(0.5)
|
||||
|
||||
def _handle_client(self, client):
|
||||
"""Handle a client connection"""
|
||||
try:
|
||||
# Set socket buffer size
|
||||
client.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 14485760) # 10MB
|
||||
client.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 14485760) # 10MB
|
||||
|
||||
while self.running:
|
||||
# Receive command with larger buffer
|
||||
data = client.recv(14485760) # 10MB buffer
|
||||
if not data:
|
||||
log_message("Client disconnected")
|
||||
break
|
||||
|
||||
try:
|
||||
command = json.loads(data.decode('utf-8'))
|
||||
log_message("Received command: {0}".format(command))
|
||||
|
||||
# Create a closure to capture the client connection
|
||||
def execute_wrapper():
|
||||
try:
|
||||
response = self.execute_command(command)
|
||||
response_json = json.dumps(response)
|
||||
# Split large responses into chunks if needed
|
||||
chunk_size = 14485760 # 10MB chunks
|
||||
response_bytes = response_json.encode('utf-8')
|
||||
for i in range(0, len(response_bytes), chunk_size):
|
||||
chunk = response_bytes[i:i + chunk_size]
|
||||
client.sendall(chunk)
|
||||
log_message("Response sent successfully")
|
||||
except Exception as e:
|
||||
log_message("Error executing command: {0}".format(str(e)))
|
||||
traceback.print_exc()
|
||||
error_response = {
|
||||
"status": "error",
|
||||
"message": str(e)
|
||||
}
|
||||
try:
|
||||
client.sendall(json.dumps(error_response).encode('utf-8'))
|
||||
except Exception as e:
|
||||
log_message("Failed to send error response: {0}".format(str(e)))
|
||||
return False # Signal connection should be closed
|
||||
return True # Signal connection should stay open
|
||||
|
||||
# Use RhinoApp.Idle event for IronPython 2.7 compatibility
|
||||
def idle_handler(sender, e):
|
||||
if not execute_wrapper():
|
||||
# If execute_wrapper returns False, close the connection
|
||||
try:
|
||||
client.close()
|
||||
except:
|
||||
pass
|
||||
# Remove the handler after execution
|
||||
Rhino.RhinoApp.Idle -= idle_handler
|
||||
|
||||
Rhino.RhinoApp.Idle += idle_handler
|
||||
|
||||
except ValueError as e:
|
||||
# Handle JSON decode error (IronPython 2.7)
|
||||
log_message("Invalid JSON received: {0}".format(str(e)))
|
||||
error_response = {
|
||||
"status": "error",
|
||||
"message": "Invalid JSON format"
|
||||
}
|
||||
try:
|
||||
client.sendall(json.dumps(error_response).encode('utf-8'))
|
||||
except:
|
||||
break # Close connection on send error
|
||||
|
||||
except Exception as e:
|
||||
log_message("Error handling client: {0}".format(str(e)))
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
try:
|
||||
client.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
def execute_command(self, command):
|
||||
"""Execute a command received from the client"""
|
||||
try:
|
||||
command_type = command.get("type")
|
||||
params = command.get("params", {})
|
||||
|
||||
if command_type == "get_scene_info":
|
||||
return self._get_scene_info(params)
|
||||
elif command_type == "create_cube":
|
||||
return self._create_cube(params)
|
||||
elif command_type == "get_layers":
|
||||
return self._get_layers()
|
||||
elif command_type == "execute_code":
|
||||
return self._execute_code(params)
|
||||
elif command_type == "get_objects_with_metadata":
|
||||
return self._get_objects_with_metadata(params)
|
||||
elif command_type == "capture_viewport":
|
||||
return self._capture_viewport(params)
|
||||
elif command_type == "add_metadata":
|
||||
return self._add_object_metadata(
|
||||
params.get("object_id"),
|
||||
params.get("name"),
|
||||
params.get("description")
|
||||
)
|
||||
else:
|
||||
return {"status": "error", "message": "Unknown command type"}
|
||||
|
||||
except Exception as e:
|
||||
log_message("Error executing command: {0}".format(str(e)))
|
||||
traceback.print_exc()
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _get_scene_info(self, params=None):
|
||||
"""Get simplified scene information focusing on layers and example objects"""
|
||||
try:
|
||||
doc = sc.doc
|
||||
if not doc:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "No active document"
|
||||
}
|
||||
|
||||
log_message("Getting simplified scene info...")
|
||||
layers_info = []
|
||||
|
||||
for layer in doc.Layers:
|
||||
layer_objects = [obj for obj in doc.Objects if obj.Attributes.LayerIndex == layer.Index]
|
||||
example_objects = []
|
||||
|
||||
for obj in layer_objects[:5]: # Limit to 5 example objects per layer
|
||||
try:
|
||||
# Convert NameValueCollection to dictionary
|
||||
user_strings = {}
|
||||
if obj.Attributes.GetUserStrings():
|
||||
for key in obj.Attributes.GetUserStrings():
|
||||
user_strings[key] = obj.Attributes.GetUserString(key)
|
||||
|
||||
obj_info = {
|
||||
"id": str(obj.Id),
|
||||
"name": obj.Name or "Unnamed",
|
||||
"type": obj.Geometry.GetType().Name if obj.Geometry else "Unknown",
|
||||
"metadata": user_strings # Now using the converted dictionary
|
||||
}
|
||||
example_objects.append(obj_info)
|
||||
except Exception as e:
|
||||
log_message("Error processing object: {0}".format(str(e)))
|
||||
continue
|
||||
|
||||
layer_info = {
|
||||
"full_path": layer.FullPath,
|
||||
"object_count": len(layer_objects),
|
||||
"is_visible": layer.IsVisible,
|
||||
"is_locked": layer.IsLocked,
|
||||
"example_objects": example_objects
|
||||
}
|
||||
layers_info.append(layer_info)
|
||||
|
||||
response = {
|
||||
"status": "success",
|
||||
"layers": layers_info
|
||||
}
|
||||
|
||||
log_message("Simplified scene info collected successfully")
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
log_message("Error getting simplified scene info: {0}".format(str(e)))
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e),
|
||||
"layers": []
|
||||
}
|
||||
|
||||
def _create_cube(self, params):
|
||||
"""Create a cube in the scene"""
|
||||
try:
|
||||
size = float(params.get("size", 1.0))
|
||||
location = params.get("location", [0, 0, 0])
|
||||
name = params.get("name", "Cube")
|
||||
|
||||
# Create cube using RhinoCommon
|
||||
box = Rhino.Geometry.Box(
|
||||
Rhino.Geometry.Plane.WorldXY,
|
||||
Rhino.Geometry.Interval(0, size),
|
||||
Rhino.Geometry.Interval(0, size),
|
||||
Rhino.Geometry.Interval(0, size)
|
||||
)
|
||||
|
||||
# Move to specified location
|
||||
transform = Rhino.Geometry.Transform.Translation(
|
||||
location[0] - box.Center.X,
|
||||
location[1] - box.Center.Y,
|
||||
location[2] - box.Center.Z
|
||||
)
|
||||
box.Transform(transform)
|
||||
|
||||
# Add to document
|
||||
id = sc.doc.Objects.AddBox(box)
|
||||
if id != System.Guid.Empty:
|
||||
obj = sc.doc.Objects.Find(id)
|
||||
if obj:
|
||||
obj.Name = name
|
||||
sc.doc.Views.Redraw()
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Created cube with size {0}".format(size),
|
||||
"id": str(id)
|
||||
}
|
||||
|
||||
return {"status": "error", "message": "Failed to create cube"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _get_layers(self):
|
||||
"""Get information about all layers"""
|
||||
try:
|
||||
doc = sc.doc
|
||||
layers = []
|
||||
|
||||
for layer in doc.Layers:
|
||||
layers.append({
|
||||
"id": layer.Index,
|
||||
"name": layer.Name,
|
||||
"object_count": layer.ObjectCount,
|
||||
"is_visible": layer.IsVisible,
|
||||
"is_locked": layer.IsLocked
|
||||
})
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"layers": layers
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _execute_code(self, params):
|
||||
"""Execute arbitrary Python code"""
|
||||
try:
|
||||
code = params.get("code", "")
|
||||
if not code:
|
||||
return {"status": "error", "message": "No code provided"}
|
||||
|
||||
log_message("Executing code: {0}".format(code))
|
||||
|
||||
# Create a new scope for code execution
|
||||
local_dict = {}
|
||||
|
||||
try:
|
||||
# Execute the code
|
||||
exec(code, globals(), local_dict)
|
||||
|
||||
# Get result from local_dict or use a default message
|
||||
result = local_dict.get("result", "Code executed successfully")
|
||||
log_message("Code execution completed. Result: {0}".format(result))
|
||||
|
||||
response = {
|
||||
"status": "success",
|
||||
"result": str(result),
|
||||
"variables": {k: str(v) for k, v in local_dict.items() if not k.startswith('__')}
|
||||
}
|
||||
|
||||
log_message("Sending response: {0}".format(json.dumps(response)))
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
hint = "Did you use f-string formatting? You have to use IronPython here that doesn't support this."
|
||||
error_response = {
|
||||
"status": "error",
|
||||
"message": "{0} {1}".format(hint, str(e)),
|
||||
}
|
||||
log_message("Error: {0}".format(error_response))
|
||||
return error_response
|
||||
|
||||
except Exception as e:
|
||||
hint = "Did you use f-string formatting? You have to use IronPython here that doesn't support this."
|
||||
error_response = {
|
||||
"status": "error",
|
||||
"message": "{0} {1}".format(hint, str(e)),
|
||||
}
|
||||
log_message("System error: {0}".format(error_response))
|
||||
return error_response
|
||||
|
||||
def _add_object_metadata(self, obj_id, name=None, description=None):
|
||||
"""Add standardized metadata to an object"""
|
||||
try:
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# Generate short ID
|
||||
short_id = datetime.now().strftime("%d%H%M%S")
|
||||
|
||||
# Get bounding box
|
||||
bbox = rs.BoundingBox(obj_id)
|
||||
bbox_data = [[p.X, p.Y, p.Z] for p in bbox] if bbox else []
|
||||
|
||||
# Get object type
|
||||
obj = sc.doc.Objects.Find(obj_id)
|
||||
obj_type = obj.Geometry.GetType().Name if obj else "Unknown"
|
||||
|
||||
# Standard metadata
|
||||
metadata = {
|
||||
"short_id": short_id,
|
||||
"created_at": time.time(),
|
||||
"layer": rs.ObjectLayer(obj_id),
|
||||
"type": obj_type,
|
||||
"bbox": bbox_data
|
||||
}
|
||||
|
||||
# User-provided metadata
|
||||
if name:
|
||||
rs.ObjectName(obj_id, name)
|
||||
metadata["name"] = name
|
||||
else:
|
||||
# Auto-generate name if none provided
|
||||
auto_name = "{0}_{1}".format(obj_type, short_id)
|
||||
rs.ObjectName(obj_id, auto_name)
|
||||
metadata["name"] = auto_name
|
||||
|
||||
if description:
|
||||
metadata["description"] = description
|
||||
|
||||
# Store metadata as user text (convert bbox to string for storage)
|
||||
user_text_data = metadata.copy()
|
||||
user_text_data["bbox"] = json.dumps(bbox_data)
|
||||
|
||||
# Add all metadata as user text
|
||||
for key, value in user_text_data.items():
|
||||
rs.SetUserText(obj_id, key, str(value))
|
||||
|
||||
return {"status": "success"}
|
||||
except Exception as e:
|
||||
log_message("Error adding metadata: " + str(e))
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
def _get_objects_with_metadata(self, params):
|
||||
"""Get objects with their metadata, with optional filtering"""
|
||||
try:
|
||||
import re
|
||||
import json
|
||||
|
||||
filters = params.get("filters", {})
|
||||
metadata_fields = params.get("metadata_fields")
|
||||
layer_filter = filters.get("layer")
|
||||
name_filter = filters.get("name")
|
||||
id_filter = filters.get("short_id")
|
||||
|
||||
# Validate metadata fields
|
||||
all_fields = VALID_METADATA_FIELDS['required'] + VALID_METADATA_FIELDS['optional']
|
||||
if metadata_fields:
|
||||
invalid_fields = [f for f in metadata_fields if f not in all_fields]
|
||||
if invalid_fields:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Invalid metadata fields: " + ", ".join(invalid_fields),
|
||||
"available_fields": all_fields
|
||||
}
|
||||
|
||||
objects = []
|
||||
|
||||
for obj in sc.doc.Objects:
|
||||
obj_id = obj.Id
|
||||
|
||||
# Apply filters
|
||||
if layer_filter:
|
||||
layer = rs.ObjectLayer(obj_id)
|
||||
pattern = "^" + layer_filter.replace("*", ".*") + "$"
|
||||
if not re.match(pattern, layer, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
if name_filter:
|
||||
name = obj.Name or ""
|
||||
pattern = "^" + name_filter.replace("*", ".*") + "$"
|
||||
if not re.match(pattern, name, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
if id_filter:
|
||||
short_id = rs.GetUserText(obj_id, "short_id") or ""
|
||||
if short_id != id_filter:
|
||||
continue
|
||||
|
||||
# Build base object data with required fields
|
||||
obj_data = {
|
||||
"id": str(obj_id),
|
||||
"name": obj.Name or "Unnamed",
|
||||
"type": obj.Geometry.GetType().Name,
|
||||
"layer": rs.ObjectLayer(obj_id)
|
||||
}
|
||||
|
||||
# Get user text data and parse stored values
|
||||
stored_data = {}
|
||||
for key in rs.GetUserText(obj_id):
|
||||
value = rs.GetUserText(obj_id, key)
|
||||
if key == "bbox":
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except:
|
||||
value = []
|
||||
elif key == "created_at":
|
||||
try:
|
||||
value = float(value)
|
||||
except:
|
||||
value = 0
|
||||
stored_data[key] = value
|
||||
|
||||
# Build metadata based on requested fields
|
||||
if metadata_fields:
|
||||
metadata = {k: stored_data[k] for k in metadata_fields if k in stored_data}
|
||||
else:
|
||||
metadata = {k: v for k, v in stored_data.items()
|
||||
if k not in VALID_METADATA_FIELDS['required']}
|
||||
|
||||
# Only include user_text if specifically requested
|
||||
if not metadata_fields or 'user_text' in metadata_fields:
|
||||
user_text = {k: v for k, v in stored_data.items()
|
||||
if k not in metadata}
|
||||
if user_text:
|
||||
obj_data["user_text"] = user_text
|
||||
|
||||
# Add metadata if we have any
|
||||
if metadata:
|
||||
obj_data["metadata"] = metadata
|
||||
|
||||
objects.append(obj_data)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"count": len(objects),
|
||||
"objects": objects,
|
||||
"available_fields": all_fields
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log_message("Error filtering objects: " + str(e))
|
||||
return {
|
||||
"status": "error",
|
||||
"message": str(e),
|
||||
"available_fields": all_fields
|
||||
}
|
||||
|
||||
def _capture_viewport(self, params):
|
||||
"""Capture viewport with optional annotations and layer filtering"""
|
||||
try:
|
||||
layer_name = params.get("layer")
|
||||
show_annotations = params.get("show_annotations", True)
|
||||
max_size = params.get("max_size", 800) # Default max dimension
|
||||
original_layer = rs.CurrentLayer()
|
||||
temp_dots = []
|
||||
|
||||
if show_annotations:
|
||||
# Ensure annotation layer exists and is current
|
||||
if not rs.IsLayer(ANNOTATION_LAYER):
|
||||
rs.AddLayer(ANNOTATION_LAYER, color=(255, 0, 0))
|
||||
rs.CurrentLayer(ANNOTATION_LAYER)
|
||||
|
||||
# Create temporary text dots for each object
|
||||
for obj in sc.doc.Objects:
|
||||
if layer_name and rs.ObjectLayer(obj.Id) != layer_name:
|
||||
continue
|
||||
|
||||
bbox = rs.BoundingBox(obj.Id)
|
||||
if bbox:
|
||||
pt = bbox[1] # Use top corner of bounding box
|
||||
short_id = rs.GetUserText(obj.Id, "short_id")
|
||||
if not short_id:
|
||||
short_id = datetime.now().strftime("%d%H%M%S")
|
||||
rs.SetUserText(obj.Id, "short_id", short_id)
|
||||
|
||||
name = rs.ObjectName(obj.Id) or "Unnamed"
|
||||
text = "{0}\n{1}".format(name, short_id)
|
||||
|
||||
dot_id = rs.AddTextDot(text, pt)
|
||||
rs.TextDotHeight(dot_id, 8)
|
||||
temp_dots.append(dot_id)
|
||||
|
||||
try:
|
||||
view = sc.doc.Views.ActiveView
|
||||
memory_stream = MemoryStream()
|
||||
|
||||
# Capture to bitmap
|
||||
bitmap = view.CaptureToBitmap()
|
||||
|
||||
# Calculate new dimensions while maintaining aspect ratio
|
||||
width, height = bitmap.Width, bitmap.Height
|
||||
if width > height:
|
||||
new_width = max_size
|
||||
new_height = int(height * (max_size / width))
|
||||
else:
|
||||
new_height = max_size
|
||||
new_width = int(width * (max_size / height))
|
||||
|
||||
# Create resized bitmap
|
||||
resized_bitmap = Bitmap(bitmap, new_width, new_height)
|
||||
|
||||
# Save as JPEG (IronPython doesn't support quality parameter)
|
||||
resized_bitmap.Save(memory_stream, ImageFormat.Jpeg)
|
||||
|
||||
bytes_array = memory_stream.ToArray()
|
||||
image_data = base64.b64encode(bytes(bytearray(bytes_array))).decode('utf-8')
|
||||
|
||||
# Clean up
|
||||
bitmap.Dispose()
|
||||
resized_bitmap.Dispose()
|
||||
memory_stream.Dispose()
|
||||
|
||||
finally:
|
||||
if temp_dots:
|
||||
rs.DeleteObjects(temp_dots)
|
||||
rs.CurrentLayer(original_layer)
|
||||
|
||||
return {
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/jpeg",
|
||||
"data": image_data
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
log_message("Error capturing viewport: " + str(e))
|
||||
if 'original_layer' in locals():
|
||||
rs.CurrentLayer(original_layer)
|
||||
return {
|
||||
"type": "text",
|
||||
"text": "Error capturing viewport: " + str(e)
|
||||
}
|
||||
|
||||
# Create and start server
|
||||
server = RhinoMCPServer(HOST, PORT)
|
||||
server.start()
|
||||
|
||||
# Add commands to Rhino
|
||||
def start_server():
|
||||
"""Start the RhinoMCP server"""
|
||||
server.start()
|
||||
|
||||
def stop_server():
|
||||
"""Stop the RhinoMCP server"""
|
||||
server.stop()
|
||||
|
||||
# Automatically start the server when this script is loaded
|
||||
start_server()
|
||||
log_message("RhinoMCP script loaded. Server started automatically.")
|
||||
log_message("To stop the server, run: stop_server()")
|
||||
Reference in new issue
Block a user