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:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# copy this file to .env and fill in the values
REPLICATE_TOKEN = <your replicate token>
+39
View File
@@ -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
+21
View File
@@ -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.
+11
View File
@@ -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()
+28
View File
@@ -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 = {"" = "."}
+14
View File
@@ -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
+7
View File
@@ -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)}"
+101
View File
@@ -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)}"
+355
View File
@@ -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
+181
View File
@@ -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()
+184
View File
@@ -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
+697
View File
@@ -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()")