From e066f9fce5cdf3aafe81d6320445cdd14934cb2d Mon Sep 17 00:00:00 2001 From: architeur Date: Mon, 29 Dec 2025 21:09:28 +0100 Subject: [PATCH] Initial commit: RhinoMCP project structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP Server (Python) with full tool definitions for Rhino/Grasshopper - Rhino Plugin (C#) with HTTP server for command execution - Support for geometry creation, manipulation, boolean operations - Grasshopper integration: sliders, toggles, components, connections - MIT License, README with architecture documentation πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .gitignore | 43 ++ LICENSE | 21 + README.md | 107 +++ src/mcp-server/requirements.txt | 5 + src/mcp-server/server.py | 705 ++++++++++++++++++ src/rhino-plugin/RhinoMCP.sln | 18 + src/rhino-plugin/RhinoMCP/CommandHandler.cs | 660 ++++++++++++++++ .../RhinoMCP/GrasshopperHandler.cs | 527 +++++++++++++ src/rhino-plugin/RhinoMCP/HttpServer.cs | 110 +++ src/rhino-plugin/RhinoMCP/RhinoMCP.csproj | 33 + src/rhino-plugin/RhinoMCP/RhinoMcpPlugin.cs | 54 ++ 11 files changed, 2283 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/mcp-server/requirements.txt create mode 100644 src/mcp-server/server.py create mode 100644 src/rhino-plugin/RhinoMCP.sln create mode 100644 src/rhino-plugin/RhinoMCP/CommandHandler.cs create mode 100644 src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs create mode 100644 src/rhino-plugin/RhinoMCP/HttpServer.cs create mode 100644 src/rhino-plugin/RhinoMCP/RhinoMCP.csproj create mode 100644 src/rhino-plugin/RhinoMCP/RhinoMcpPlugin.cs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea44a4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +ENV/ +.env +*.egg-info/ +dist/ +build/ + +# C# / Visual Studio +bin/ +obj/ +*.user +*.suo +*.userosscache +*.sln.docstates +.vs/ +*.dll +*.exe +*.pdb +*.rhp + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Project specific +*.log +temp/ + +# Keep examples +!examples/*.gh +!examples/*.3dm diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..029ae0b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 RhinoMCP Contributors + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d568bc3 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# RhinoMCP + +Ein MCP-Server (Model Context Protocol) fΓΌr die Integration von Claude AI mit Rhino 3D und Grasshopper. + +## Features + +- **Rhino-Geometrie erstellen** - Punkte, Linien, Kurven, FlΓ€chen, VolumenkΓΆrper +- **Grasshopper-Definitionen** - Komponenten erstellen, verbinden, Parameter setzen +- **GH-Dateien laden/modifizieren** - Bestehende Grasshopper-Definitionen bearbeiten +- **Geometrie analysieren** - Maße, AbstΓ€nde, FlΓ€chen, Volumen berechnen +- **Export** - Verschiedene Formate (3DM, STEP, IGES, STL, etc.) + +## Architektur + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Claude Code / Claude.ai β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ MCP Protocol (stdio/SSE) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP-Server (Python) β”‚ +β”‚ - Tool-Definitionen β”‚ +β”‚ - Request/Response Handling β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ HTTP (localhost:9000) +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Rhino-Plugin (C# .rhp) β”‚ +β”‚ β”œβ”€ RhinoCommon API (Geometrie) β”‚ +β”‚ β”œβ”€ Grasshopper SDK (Komponenten, Verbindungen) β”‚ +β”‚ └─ HTTP-Server (empfΓ€ngt Befehle) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Voraussetzungen + +- Rhino 8 oder Rhino 9 WIP +- Python 3.10+ +- .NET Framework 4.8 / .NET 7.0 (fΓΌr Rhino 8) + +## Installation + +### 1. MCP-Server (Python) + +```bash +cd src/mcp-server +pip install -r requirements.txt +``` + +### 2. Rhino-Plugin + +1. Γ–ffne `src/rhino-plugin/RhinoMCP.sln` in Visual Studio +2. Build das Projekt +3. Kopiere `RhinoMCP.rhp` nach `%APPDATA%\McNeel\Rhinoceros\8.0\Plug-ins\` +4. Starte Rhino und aktiviere das Plugin + +### 3. Claude Code Konfiguration + +FΓΌge in `~/.claude/claude_desktop_config.json` hinzu: + +```json +{ + "mcpServers": { + "rhino": { + "command": "python", + "args": ["C:/Entwicklung/Rhino-MCP/src/mcp-server/server.py"] + } + } +} +``` + +## Verwendung + +Nach der Installation kann Claude direkt mit Rhino kommunizieren: + +``` +"Erstelle einen WΓΌrfel mit 10mm KantenlΓ€nge" +"Γ–ffne die Grasshopper-Definition und setze den Slider 'Breite' auf 500" +"Exportiere die aktuelle Geometrie als STEP-Datei" +``` + +## Projektstruktur + +``` +RhinoMCP/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ mcp-server/ # Python MCP-Server +β”‚ β”‚ β”œβ”€β”€ server.py # Hauptserver (stdio + SSE) +β”‚ β”‚ β”œβ”€β”€ tools/ # Tool-Implementierungen +β”‚ β”‚ └── requirements.txt +β”‚ └── rhino-plugin/ # C# Rhino-Plugin +β”‚ └── RhinoMCP/ +β”‚ β”œβ”€β”€ RhinoMCP.csproj +β”‚ β”œβ”€β”€ Plugin.cs +β”‚ └── HttpServer.cs +β”œβ”€β”€ docs/ # Dokumentation +β”œβ”€β”€ examples/ # Beispiel-Definitionen +β”œβ”€β”€ LICENSE +└── README.md +``` + +## Lizenz + +MIT License - siehe [LICENSE](LICENSE) + +## Mitwirken + +BeitrΓ€ge sind willkommen! Bitte erstelle einen Issue oder Pull Request. diff --git a/src/mcp-server/requirements.txt b/src/mcp-server/requirements.txt new file mode 100644 index 0000000..c131b20 --- /dev/null +++ b/src/mcp-server/requirements.txt @@ -0,0 +1,5 @@ +mcp>=1.0.0 +httpx>=0.25.0 +pydantic>=2.0.0 +uvicorn>=0.24.0 +starlette>=0.32.0 diff --git a/src/mcp-server/server.py b/src/mcp-server/server.py new file mode 100644 index 0000000..b89ccfd --- /dev/null +++ b/src/mcp-server/server.py @@ -0,0 +1,705 @@ +#!/usr/bin/env python3 +""" +RhinoMCP - MCP Server for Rhino 3D and Grasshopper integration. + +This server provides tools for Claude to interact with Rhino and Grasshopper +via the Model Context Protocol (MCP). +""" + +import asyncio +import json +import logging +from typing import Any + +import httpx +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("rhino-mcp") + +# Rhino Plugin HTTP endpoint +RHINO_ENDPOINT = "http://localhost:9000" + +# Create MCP server instance +server = Server("rhino-mcp") + + +async def call_rhino(action: str, params: dict[str, Any] = None) -> dict[str, Any]: + """Send a command to the Rhino plugin and return the response.""" + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.post( + f"{RHINO_ENDPOINT}/execute", + json={"action": action, "params": params or {}} + ) + response.raise_for_status() + return response.json() + except httpx.ConnectError: + return {"error": "Cannot connect to Rhino. Is the RhinoMCP plugin running?"} + except Exception as e: + return {"error": str(e)} + + +@server.list_tools() +async def list_tools() -> list[Tool]: + """Return the list of available tools.""" + return [ + # === Rhino Document Tools === + Tool( + name="rhino_get_document_info", + description="Get information about the current Rhino document (file path, units, layers, object count)", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + Tool( + name="rhino_get_selected_objects", + description="Get information about currently selected objects in Rhino", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + Tool( + name="rhino_get_layers", + description="Get all layers in the current Rhino document", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + + # === Geometry Creation Tools === + Tool( + name="rhino_create_point", + description="Create a point in Rhino at the specified coordinates", + inputSchema={ + "type": "object", + "properties": { + "x": {"type": "number", "description": "X coordinate"}, + "y": {"type": "number", "description": "Y coordinate"}, + "z": {"type": "number", "description": "Z coordinate", "default": 0} + }, + "required": ["x", "y"] + } + ), + Tool( + name="rhino_create_line", + description="Create a line between two points", + inputSchema={ + "type": "object", + "properties": { + "start": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "end": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + } + }, + "required": ["start", "end"] + } + ), + Tool( + name="rhino_create_polyline", + description="Create a polyline from a list of points", + inputSchema={ + "type": "object", + "properties": { + "points": { + "type": "array", + "items": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "description": "List of points defining the polyline" + }, + "closed": {"type": "boolean", "default": False, "description": "Whether to close the polyline"} + }, + "required": ["points"] + } + ), + Tool( + name="rhino_create_circle", + description="Create a circle with center point and radius", + inputSchema={ + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "radius": {"type": "number", "description": "Circle radius"} + }, + "required": ["center", "radius"] + } + ), + Tool( + name="rhino_create_rectangle", + description="Create a rectangle from corner point, width and height", + inputSchema={ + "type": "object", + "properties": { + "corner": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "width": {"type": "number", "description": "Rectangle width"}, + "height": {"type": "number", "description": "Rectangle height"} + }, + "required": ["corner", "width", "height"] + } + ), + Tool( + name="rhino_create_sphere", + description="Create a sphere with center point and radius", + inputSchema={ + "type": "object", + "properties": { + "center": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "radius": {"type": "number", "description": "Sphere radius"} + }, + "required": ["center", "radius"] + } + ), + Tool( + name="rhino_create_box", + description="Create a box (cuboid) from corner point and dimensions", + inputSchema={ + "type": "object", + "properties": { + "corner": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "width": {"type": "number", "description": "Box width (X)"}, + "depth": {"type": "number", "description": "Box depth (Y)"}, + "height": {"type": "number", "description": "Box height (Z)"} + }, + "required": ["corner", "width", "depth", "height"] + } + ), + Tool( + name="rhino_create_cylinder", + description="Create a cylinder from base center, radius and height", + inputSchema={ + "type": "object", + "properties": { + "base_center": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "radius": {"type": "number", "description": "Cylinder radius"}, + "height": {"type": "number", "description": "Cylinder height"} + }, + "required": ["base_center", "radius", "height"] + } + ), + Tool( + name="rhino_create_surface_from_curves", + description="Create a surface from boundary curves (planar surface or loft)", + inputSchema={ + "type": "object", + "properties": { + "curve_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of curve GUIDs to create surface from" + }, + "method": { + "type": "string", + "enum": ["planar", "loft"], + "default": "planar", + "description": "Surface creation method" + } + }, + "required": ["curve_ids"] + } + ), + Tool( + name="rhino_extrude_curve", + description="Extrude a curve along a direction to create a surface or solid", + inputSchema={ + "type": "object", + "properties": { + "curve_id": {"type": "string", "description": "GUID of the curve to extrude"}, + "direction": { + "type": "object", + "properties": { + "x": {"type": "number", "default": 0}, + "y": {"type": "number", "default": 0}, + "z": {"type": "number", "default": 1} + }, + "description": "Extrusion direction vector" + }, + "distance": {"type": "number", "description": "Extrusion distance"}, + "cap": {"type": "boolean", "default": True, "description": "Cap the ends to create a solid"} + }, + "required": ["curve_id", "distance"] + } + ), + + # === Object Manipulation Tools === + Tool( + name="rhino_move_object", + description="Move an object by a translation vector", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object to move"}, + "translation": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + } + }, + "required": ["object_id", "translation"] + } + ), + Tool( + name="rhino_copy_object", + description="Copy an object with optional translation", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object to copy"}, + "translation": { + "type": "object", + "properties": { + "x": {"type": "number", "default": 0}, + "y": {"type": "number", "default": 0}, + "z": {"type": "number", "default": 0} + }, + "description": "Optional translation for the copy" + } + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_rotate_object", + description="Rotate an object around an axis", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object to rotate"}, + "angle_degrees": {"type": "number", "description": "Rotation angle in degrees"}, + "axis": { + "type": "string", + "enum": ["x", "y", "z"], + "default": "z", + "description": "Rotation axis" + }, + "center": { + "type": "object", + "properties": { + "x": {"type": "number", "default": 0}, + "y": {"type": "number", "default": 0}, + "z": {"type": "number", "default": 0} + }, + "description": "Rotation center point" + } + }, + "required": ["object_id", "angle_degrees"] + } + ), + Tool( + name="rhino_scale_object", + description="Scale an object from a base point", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object to scale"}, + "scale_factor": {"type": "number", "description": "Uniform scale factor"}, + "base_point": { + "type": "object", + "properties": { + "x": {"type": "number", "default": 0}, + "y": {"type": "number", "default": 0}, + "z": {"type": "number", "default": 0} + }, + "description": "Base point for scaling" + } + }, + "required": ["object_id", "scale_factor"] + } + ), + Tool( + name="rhino_delete_object", + description="Delete an object by its GUID", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object to delete"} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_set_layer", + description="Move an object to a specific layer", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object"}, + "layer_name": {"type": "string", "description": "Name of the target layer"} + }, + "required": ["object_id", "layer_name"] + } + ), + + # === Analysis Tools === + Tool( + name="rhino_get_object_info", + description="Get detailed information about an object (type, dimensions, area, volume)", + inputSchema={ + "type": "object", + "properties": { + "object_id": {"type": "string", "description": "GUID of the object"} + }, + "required": ["object_id"] + } + ), + Tool( + name="rhino_measure_distance", + description="Measure distance between two points", + inputSchema={ + "type": "object", + "properties": { + "point1": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + }, + "point2": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + "z": {"type": "number", "default": 0} + }, + "required": ["x", "y"] + } + }, + "required": ["point1", "point2"] + } + ), + + # === Boolean Operations === + Tool( + name="rhino_boolean_union", + description="Create a boolean union of multiple objects", + inputSchema={ + "type": "object", + "properties": { + "object_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of object GUIDs to union" + }, + "delete_input": {"type": "boolean", "default": True, "description": "Delete input objects after union"} + }, + "required": ["object_ids"] + } + ), + Tool( + name="rhino_boolean_difference", + description="Subtract one or more objects from a base object", + inputSchema={ + "type": "object", + "properties": { + "base_id": {"type": "string", "description": "GUID of the base object"}, + "subtract_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of object GUIDs to subtract" + }, + "delete_input": {"type": "boolean", "default": True, "description": "Delete input objects after operation"} + }, + "required": ["base_id", "subtract_ids"] + } + ), + Tool( + name="rhino_boolean_intersection", + description="Create a boolean intersection of objects", + inputSchema={ + "type": "object", + "properties": { + "object_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "List of object GUIDs to intersect" + }, + "delete_input": {"type": "boolean", "default": True, "description": "Delete input objects after operation"} + }, + "required": ["object_ids"] + } + ), + + # === Export Tools === + Tool( + name="rhino_export", + description="Export geometry to a file", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Output file path"}, + "format": { + "type": "string", + "enum": ["3dm", "step", "iges", "stl", "obj", "fbx", "dxf"], + "description": "Export format" + }, + "object_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: specific objects to export (empty = all)" + } + }, + "required": ["file_path", "format"] + } + ), + + # === Grasshopper Tools === + Tool( + name="grasshopper_open_definition", + description="Open a Grasshopper definition file", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to the .gh or .ghx file"} + }, + "required": ["file_path"] + } + ), + Tool( + name="grasshopper_get_definition_info", + description="Get information about the current Grasshopper definition (components, parameters, connections)", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + Tool( + name="grasshopper_set_slider_value", + description="Set the value of a number slider in Grasshopper", + inputSchema={ + "type": "object", + "properties": { + "slider_name": {"type": "string", "description": "Name or nickname of the slider"}, + "value": {"type": "number", "description": "New value for the slider"} + }, + "required": ["slider_name", "value"] + } + ), + Tool( + name="grasshopper_set_toggle_value", + description="Set the value of a boolean toggle in Grasshopper", + inputSchema={ + "type": "object", + "properties": { + "toggle_name": {"type": "string", "description": "Name or nickname of the toggle"}, + "value": {"type": "boolean", "description": "New value for the toggle"} + }, + "required": ["toggle_name", "value"] + } + ), + Tool( + name="grasshopper_set_panel_text", + description="Set the text content of a panel in Grasshopper", + inputSchema={ + "type": "object", + "properties": { + "panel_name": {"type": "string", "description": "Name or nickname of the panel"}, + "text": {"type": "string", "description": "New text content"} + }, + "required": ["panel_name", "text"] + } + ), + Tool( + name="grasshopper_add_component", + description="Add a component to the Grasshopper canvas", + inputSchema={ + "type": "object", + "properties": { + "component_name": {"type": "string", "description": "Name of the component (e.g., 'Circle', 'Move', 'Extrude')"}, + "position": { + "type": "object", + "properties": { + "x": {"type": "number", "description": "X position on canvas"}, + "y": {"type": "number", "description": "Y position on canvas"} + }, + "required": ["x", "y"] + } + }, + "required": ["component_name", "position"] + } + ), + Tool( + name="grasshopper_connect_components", + description="Connect two components in Grasshopper", + inputSchema={ + "type": "object", + "properties": { + "source_component": {"type": "string", "description": "Name/ID of source component"}, + "source_output": {"type": "string", "description": "Name of output parameter"}, + "target_component": {"type": "string", "description": "Name/ID of target component"}, + "target_input": {"type": "string", "description": "Name of input parameter"} + }, + "required": ["source_component", "source_output", "target_component", "target_input"] + } + ), + Tool( + name="grasshopper_add_slider", + description="Add a number slider to the Grasshopper canvas", + inputSchema={ + "type": "object", + "properties": { + "name": {"type": "string", "description": "Nickname for the slider"}, + "min_value": {"type": "number", "description": "Minimum value"}, + "max_value": {"type": "number", "description": "Maximum value"}, + "current_value": {"type": "number", "description": "Current/default value"}, + "position": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"} + }, + "required": ["x", "y"] + } + }, + "required": ["name", "min_value", "max_value", "current_value", "position"] + } + ), + Tool( + name="grasshopper_bake", + description="Bake Grasshopper geometry to Rhino", + inputSchema={ + "type": "object", + "properties": { + "component_name": {"type": "string", "description": "Name of the component to bake (optional, bakes all if not specified)"}, + "layer_name": {"type": "string", "description": "Target layer for baked geometry"} + }, + "required": [] + } + ), + Tool( + name="grasshopper_save_definition", + description="Save the current Grasshopper definition", + inputSchema={ + "type": "object", + "properties": { + "file_path": {"type": "string", "description": "Path to save the file (optional, saves to current if not specified)"} + }, + "required": [] + } + ), + Tool( + name="grasshopper_recompute", + description="Force recompute of the Grasshopper definition", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + } + ), + + # === Rhino Commands === + Tool( + name="rhino_run_command", + description="Run a Rhino command by name (advanced usage)", + inputSchema={ + "type": "object", + "properties": { + "command": {"type": "string", "description": "Rhino command string (e.g., '_Zoom _All')"} + }, + "required": ["command"] + } + ), + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + """Handle tool calls by forwarding them to Rhino.""" + logger.info(f"Tool called: {name} with arguments: {arguments}") + + # Forward the tool call to Rhino + result = await call_rhino(name, arguments) + + return [TextContent(type="text", text=json.dumps(result, indent=2, ensure_ascii=False))] + + +async def main(): + """Run the MCP server.""" + logger.info("Starting RhinoMCP server...") + + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options() + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/src/rhino-plugin/RhinoMCP.sln b/src/rhino-plugin/RhinoMCP.sln new file mode 100644 index 0000000..58c022f --- /dev/null +++ b/src/rhino-plugin/RhinoMCP.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RhinoMCP", "RhinoMCP\RhinoMCP.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/rhino-plugin/RhinoMCP/CommandHandler.cs b/src/rhino-plugin/RhinoMCP/CommandHandler.cs new file mode 100644 index 0000000..41cf147 --- /dev/null +++ b/src/rhino-plugin/RhinoMCP/CommandHandler.cs @@ -0,0 +1,660 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; +using Rhino; +using Rhino.DocObjects; +using Rhino.Geometry; + +namespace RhinoMCP +{ + /// + /// Handles all commands from the MCP Server and executes them in Rhino. + /// + public static class CommandHandler + { + public static object Execute(string action, JObject parameters) + { + var doc = RhinoDoc.ActiveDoc; + if (doc == null && !action.StartsWith("rhino_get")) + return new { error = "No active Rhino document" }; + + return action switch + { + // Document Info + "rhino_get_document_info" => GetDocumentInfo(doc), + "rhino_get_selected_objects" => GetSelectedObjects(doc), + "rhino_get_layers" => GetLayers(doc), + + // Geometry Creation + "rhino_create_point" => CreatePoint(doc, parameters), + "rhino_create_line" => CreateLine(doc, parameters), + "rhino_create_polyline" => CreatePolyline(doc, parameters), + "rhino_create_circle" => CreateCircle(doc, parameters), + "rhino_create_rectangle" => CreateRectangle(doc, parameters), + "rhino_create_sphere" => CreateSphere(doc, parameters), + "rhino_create_box" => CreateBox(doc, parameters), + "rhino_create_cylinder" => CreateCylinder(doc, parameters), + "rhino_create_surface_from_curves" => CreateSurfaceFromCurves(doc, parameters), + "rhino_extrude_curve" => ExtrudeCurve(doc, parameters), + + // Object Manipulation + "rhino_move_object" => MoveObject(doc, parameters), + "rhino_copy_object" => CopyObject(doc, parameters), + "rhino_rotate_object" => RotateObject(doc, parameters), + "rhino_scale_object" => ScaleObject(doc, parameters), + "rhino_delete_object" => DeleteObject(doc, parameters), + "rhino_set_layer" => SetObjectLayer(doc, parameters), + + // Analysis + "rhino_get_object_info" => GetObjectInfo(doc, parameters), + "rhino_measure_distance" => MeasureDistance(parameters), + + // Boolean Operations + "rhino_boolean_union" => BooleanUnion(doc, parameters), + "rhino_boolean_difference" => BooleanDifference(doc, parameters), + "rhino_boolean_intersection" => BooleanIntersection(doc, parameters), + + // Export + "rhino_export" => ExportGeometry(doc, parameters), + + // Rhino Commands + "rhino_run_command" => RunCommand(parameters), + + // Grasshopper (delegated to GrasshopperHandler) + var gh when gh.StartsWith("grasshopper_") => GrasshopperHandler.Execute(action, parameters), + + _ => new { error = $"Unknown action: {action}" } + }; + } + + #region Document Info + + private static object GetDocumentInfo(RhinoDoc? doc) + { + if (doc == null) + return new { error = "No active document" }; + + return new + { + file_path = doc.Path ?? "(unsaved)", + name = doc.Name ?? "Untitled", + units = doc.ModelUnitSystem.ToString(), + object_count = doc.Objects.Count, + layer_count = doc.Layers.Count + }; + } + + private static object GetSelectedObjects(RhinoDoc? doc) + { + if (doc == null) + return new { objects = Array.Empty() }; + + var selected = doc.Objects.GetSelectedObjects(false, false); + var objects = selected.Select(obj => new + { + id = obj.Id.ToString(), + type = obj.ObjectType.ToString(), + name = obj.Name ?? "", + layer = doc.Layers[obj.Attributes.LayerIndex].Name + }).ToList(); + + return new { objects, count = objects.Count }; + } + + private static object GetLayers(RhinoDoc? doc) + { + if (doc == null) + return new { layers = Array.Empty() }; + + var layers = doc.Layers.Select(l => new + { + name = l.Name, + full_path = l.FullPath, + visible = l.IsVisible, + locked = l.IsLocked, + color = $"#{l.Color.R:X2}{l.Color.G:X2}{l.Color.B:X2}" + }).ToList(); + + return new { layers }; + } + + #endregion + + #region Geometry Creation + + private static Point3d ParsePoint(JObject? p) + { + if (p == null) return Point3d.Origin; + return new Point3d( + p["x"]?.Value() ?? 0, + p["y"]?.Value() ?? 0, + p["z"]?.Value() ?? 0 + ); + } + + private static Vector3d ParseVector(JObject? v) + { + if (v == null) return Vector3d.ZAxis; + return new Vector3d( + v["x"]?.Value() ?? 0, + v["y"]?.Value() ?? 0, + v["z"]?.Value() ?? 1 + ); + } + + private static object CreatePoint(RhinoDoc doc, JObject p) + { + var pt = new Point3d( + p["x"]?.Value() ?? 0, + p["y"]?.Value() ?? 0, + p["z"]?.Value() ?? 0 + ); + + var id = doc.Objects.AddPoint(pt); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), point = new { pt.X, pt.Y, pt.Z } }; + } + + private static object CreateLine(RhinoDoc doc, JObject p) + { + var start = ParsePoint(p["start"] as JObject); + var end = ParsePoint(p["end"] as JObject); + var line = new Line(start, end); + + var id = doc.Objects.AddLine(line); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), length = line.Length }; + } + + private static object CreatePolyline(RhinoDoc doc, JObject p) + { + var points = (p["points"] as JArray)?.Select(pt => ParsePoint(pt as JObject)).ToList(); + if (points == null || points.Count < 2) + return new { error = "At least 2 points required" }; + + var closed = p["closed"]?.Value() ?? false; + if (closed && points.First() != points.Last()) + points.Add(points.First()); + + var polyline = new Polyline(points); + var id = doc.Objects.AddPolyline(polyline); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), point_count = points.Count }; + } + + private static object CreateCircle(RhinoDoc doc, JObject p) + { + var center = ParsePoint(p["center"] as JObject); + var radius = p["radius"]?.Value() ?? 1; + + var circle = new Circle(center, radius); + var id = doc.Objects.AddCircle(circle); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), radius, circumference = circle.Circumference }; + } + + private static object CreateRectangle(RhinoDoc doc, JObject p) + { + var corner = ParsePoint(p["corner"] as JObject); + var width = p["width"]?.Value() ?? 1; + var height = p["height"]?.Value() ?? 1; + + var plane = new Plane(corner, Vector3d.ZAxis); + var rect = new Rectangle3d(plane, width, height); + var id = doc.Objects.AddRectangle(rect); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), width, height, area = width * height }; + } + + private static object CreateSphere(RhinoDoc doc, JObject p) + { + var center = ParsePoint(p["center"] as JObject); + var radius = p["radius"]?.Value() ?? 1; + + var sphere = new Sphere(center, radius); + var id = doc.Objects.AddSphere(sphere); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), radius, volume = sphere.Volume() }; + } + + private static object CreateBox(RhinoDoc doc, JObject p) + { + var corner = ParsePoint(p["corner"] as JObject); + var width = p["width"]?.Value() ?? 1; + var depth = p["depth"]?.Value() ?? 1; + var height = p["height"]?.Value() ?? 1; + + var plane = new Plane(corner, Vector3d.ZAxis); + var interval_x = new Interval(0, width); + var interval_y = new Interval(0, depth); + var interval_z = new Interval(0, height); + var box = new Box(plane, interval_x, interval_y, interval_z); + + var brep = box.ToBrep(); + var id = doc.Objects.AddBrep(brep); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString(), width, depth, height, volume = width * depth * height }; + } + + private static object CreateCylinder(RhinoDoc doc, JObject p) + { + var baseCenter = ParsePoint(p["base_center"] as JObject); + var radius = p["radius"]?.Value() ?? 1; + var height = p["height"]?.Value() ?? 1; + + var plane = new Plane(baseCenter, Vector3d.ZAxis); + var circle = new Circle(plane, radius); + var cylinder = new Cylinder(circle, height); + + var brep = cylinder.ToBrep(true, true); + var id = doc.Objects.AddBrep(brep); + doc.Views.Redraw(); + + var volume = Math.PI * radius * radius * height; + return new { success = true, id = id.ToString(), radius, height, volume }; + } + + private static object CreateSurfaceFromCurves(RhinoDoc doc, JObject p) + { + var curveIds = (p["curve_ids"] as JArray)?.Select(id => Guid.Parse(id.ToString())).ToList(); + if (curveIds == null || curveIds.Count == 0) + return new { error = "No curve IDs provided" }; + + var curves = curveIds.Select(id => doc.Objects.Find(id)?.Geometry as Curve).Where(c => c != null).ToList(); + if (curves.Count == 0) + return new { error = "No valid curves found" }; + + var method = p["method"]?.ToString() ?? "planar"; + + Brep[]? breps = null; + if (method == "planar") + { + breps = Brep.CreatePlanarBreps(curves!, RhinoDoc.ActiveDoc.ModelAbsoluteTolerance); + } + else if (method == "loft") + { + var loftBreps = Brep.CreateFromLoft(curves!, Point3d.Unset, Point3d.Unset, LoftType.Normal, false); + breps = loftBreps; + } + + if (breps == null || breps.Length == 0) + return new { error = "Failed to create surface" }; + + var ids = breps.Select(b => doc.Objects.AddBrep(b).ToString()).ToList(); + doc.Views.Redraw(); + + return new { success = true, ids, count = ids.Count }; + } + + private static object ExtrudeCurve(RhinoDoc doc, JObject p) + { + var curveId = Guid.Parse(p["curve_id"]?.ToString() ?? ""); + var curve = doc.Objects.Find(curveId)?.Geometry as Curve; + if (curve == null) + return new { error = "Curve not found" }; + + var direction = ParseVector(p["direction"] as JObject); + var distance = p["distance"]?.Value() ?? 1; + var cap = p["cap"]?.Value() ?? true; + + var extrudeDir = direction * distance; + var surface = Surface.CreateExtrusion(curve, extrudeDir); + if (surface == null) + return new { error = "Failed to create extrusion" }; + + var brep = surface.ToBrep(); + if (cap && curve.IsClosed) + { + brep = brep.CapPlanarHoles(doc.ModelAbsoluteTolerance); + } + + var id = doc.Objects.AddBrep(brep); + doc.Views.Redraw(); + + return new { success = true, id = id.ToString() }; + } + + #endregion + + #region Object Manipulation + + private static object MoveObject(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var translation = ParseVector(p["translation"] as JObject); + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var xform = Transform.Translation(translation); + doc.Objects.Transform(objId, xform, true); + doc.Views.Redraw(); + + return new { success = true, id = objId.ToString() }; + } + + private static object CopyObject(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var translation = ParseVector(p["translation"] as JObject); + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var xform = Transform.Translation(translation); + var newId = doc.Objects.Transform(objId, xform, false); + doc.Views.Redraw(); + + return new { success = true, original_id = objId.ToString(), new_id = newId.ToString() }; + } + + private static object RotateObject(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var angleDegrees = p["angle_degrees"]?.Value() ?? 0; + var axis = p["axis"]?.ToString() ?? "z"; + var center = ParsePoint(p["center"] as JObject); + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var axisVector = axis.ToLower() switch + { + "x" => Vector3d.XAxis, + "y" => Vector3d.YAxis, + _ => Vector3d.ZAxis + }; + + var angleRadians = angleDegrees * Math.PI / 180.0; + var xform = Transform.Rotation(angleRadians, axisVector, center); + doc.Objects.Transform(objId, xform, true); + doc.Views.Redraw(); + + return new { success = true, id = objId.ToString() }; + } + + private static object ScaleObject(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var scaleFactor = p["scale_factor"]?.Value() ?? 1; + var basePoint = ParsePoint(p["base_point"] as JObject); + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var xform = Transform.Scale(basePoint, scaleFactor); + doc.Objects.Transform(objId, xform, true); + doc.Views.Redraw(); + + return new { success = true, id = objId.ToString() }; + } + + private static object DeleteObject(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var success = doc.Objects.Delete(objId, true); + doc.Views.Redraw(); + + return new { success, id = objId.ToString() }; + } + + private static object SetObjectLayer(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var layerName = p["layer_name"]?.ToString() ?? ""; + + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var layerIndex = doc.Layers.FindByFullPath(layerName, -1); + if (layerIndex < 0) + layerIndex = doc.Layers.Add(layerName, System.Drawing.Color.Black); + + var attributes = obj.Attributes.Duplicate(); + attributes.LayerIndex = layerIndex; + doc.Objects.ModifyAttributes(objId, attributes, true); + doc.Views.Redraw(); + + return new { success = true, id = objId.ToString(), layer = layerName }; + } + + #endregion + + #region Analysis + + private static object GetObjectInfo(RhinoDoc doc, JObject p) + { + var objId = Guid.Parse(p["object_id"]?.ToString() ?? ""); + var obj = doc.Objects.Find(objId); + if (obj == null) + return new { error = "Object not found" }; + + var info = new Dictionary + { + ["id"] = objId.ToString(), + ["type"] = obj.ObjectType.ToString(), + ["name"] = obj.Name ?? "", + ["layer"] = doc.Layers[obj.Attributes.LayerIndex].Name + }; + + var geometry = obj.Geometry; + var bbox = geometry.GetBoundingBox(true); + info["bounding_box"] = new + { + min = new { bbox.Min.X, bbox.Min.Y, bbox.Min.Z }, + max = new { bbox.Max.X, bbox.Max.Y, bbox.Max.Z }, + diagonal = bbox.Diagonal.Length + }; + + if (geometry is Curve curve) + { + info["length"] = curve.GetLength(); + info["is_closed"] = curve.IsClosed; + } + else if (geometry is Brep brep) + { + var area = AreaMassProperties.Compute(brep); + var volume = VolumeMassProperties.Compute(brep); + if (area != null) info["area"] = area.Area; + if (volume != null) info["volume"] = volume.Volume; + } + else if (geometry is Surface surface) + { + var area = AreaMassProperties.Compute(surface); + if (area != null) info["area"] = area.Area; + } + + return info; + } + + private static object MeasureDistance(JObject p) + { + var pt1 = ParsePoint(p["point1"] as JObject); + var pt2 = ParsePoint(p["point2"] as JObject); + var distance = pt1.DistanceTo(pt2); + + return new + { + distance, + point1 = new { pt1.X, pt1.Y, pt1.Z }, + point2 = new { pt2.X, pt2.Y, pt2.Z } + }; + } + + #endregion + + #region Boolean Operations + + private static object BooleanUnion(RhinoDoc doc, JObject p) + { + var objectIds = (p["object_ids"] as JArray)?.Select(id => Guid.Parse(id.ToString())).ToList(); + var deleteInput = p["delete_input"]?.Value() ?? true; + + if (objectIds == null || objectIds.Count < 2) + return new { error = "At least 2 objects required for union" }; + + var breps = objectIds + .Select(id => doc.Objects.Find(id)?.Geometry as Brep) + .Where(b => b != null) + .ToList(); + + if (breps.Count < 2) + return new { error = "At least 2 valid breps required" }; + + var result = Brep.CreateBooleanUnion(breps!, doc.ModelAbsoluteTolerance); + if (result == null || result.Length == 0) + return new { error = "Boolean union failed" }; + + if (deleteInput) + { + foreach (var id in objectIds) + doc.Objects.Delete(id, true); + } + + var newIds = result.Select(b => doc.Objects.AddBrep(b).ToString()).ToList(); + doc.Views.Redraw(); + + return new { success = true, ids = newIds }; + } + + private static object BooleanDifference(RhinoDoc doc, JObject p) + { + var baseId = Guid.Parse(p["base_id"]?.ToString() ?? ""); + var subtractIds = (p["subtract_ids"] as JArray)?.Select(id => Guid.Parse(id.ToString())).ToList(); + var deleteInput = p["delete_input"]?.Value() ?? true; + + if (subtractIds == null || subtractIds.Count == 0) + return new { error = "No objects to subtract" }; + + var baseBrep = doc.Objects.Find(baseId)?.Geometry as Brep; + if (baseBrep == null) + return new { error = "Base object not found or not a brep" }; + + var subtractBreps = subtractIds + .Select(id => doc.Objects.Find(id)?.Geometry as Brep) + .Where(b => b != null) + .ToList(); + + var result = Brep.CreateBooleanDifference(baseBrep, subtractBreps!, doc.ModelAbsoluteTolerance); + if (result == null || result.Length == 0) + return new { error = "Boolean difference failed" }; + + if (deleteInput) + { + doc.Objects.Delete(baseId, true); + foreach (var id in subtractIds) + doc.Objects.Delete(id, true); + } + + var newIds = result.Select(b => doc.Objects.AddBrep(b).ToString()).ToList(); + doc.Views.Redraw(); + + return new { success = true, ids = newIds }; + } + + private static object BooleanIntersection(RhinoDoc doc, JObject p) + { + var objectIds = (p["object_ids"] as JArray)?.Select(id => Guid.Parse(id.ToString())).ToList(); + var deleteInput = p["delete_input"]?.Value() ?? true; + + if (objectIds == null || objectIds.Count < 2) + return new { error = "At least 2 objects required for intersection" }; + + var breps = objectIds + .Select(id => doc.Objects.Find(id)?.Geometry as Brep) + .Where(b => b != null) + .ToList(); + + if (breps.Count < 2) + return new { error = "At least 2 valid breps required" }; + + var result = Brep.CreateBooleanIntersection(breps![0], breps[1], doc.ModelAbsoluteTolerance); + if (result == null || result.Length == 0) + return new { error = "Boolean intersection failed" }; + + if (deleteInput) + { + foreach (var id in objectIds) + doc.Objects.Delete(id, true); + } + + var newIds = result.Select(b => doc.Objects.AddBrep(b).ToString()).ToList(); + doc.Views.Redraw(); + + return new { success = true, ids = newIds }; + } + + #endregion + + #region Export + + private static object ExportGeometry(RhinoDoc doc, JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + var format = p["format"]?.ToString() ?? "3dm"; + var objectIds = (p["object_ids"] as JArray)?.Select(id => Guid.Parse(id.ToString())).ToList(); + + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + // Select objects for export if specified + if (objectIds != null && objectIds.Count > 0) + { + doc.Objects.UnselectAll(); + foreach (var id in objectIds) + { + var obj = doc.Objects.Find(id); + if (obj != null) + obj.Select(true); + } + } + + var command = format.ToLower() switch + { + "3dm" => $"_-SaveAs \"{filePath}\"", + "step" => $"_-Export \"{filePath}\" _Enter", + "iges" => $"_-Export \"{filePath}\" _Enter", + "stl" => $"_-Export \"{filePath}\" _Enter", + "obj" => $"_-Export \"{filePath}\" _Enter", + "fbx" => $"_-Export \"{filePath}\" _Enter", + "dxf" => $"_-Export \"{filePath}\" _Enter", + _ => null + }; + + if (command == null) + return new { error = $"Unknown format: {format}" }; + + var result = RhinoApp.RunScript(command, false); + return new { success = result, file_path = filePath, format }; + } + + #endregion + + #region Rhino Commands + + private static object RunCommand(JObject p) + { + var command = p["command"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(command)) + return new { error = "No command specified" }; + + var result = RhinoApp.RunScript(command, true); + return new { success = result, command }; + } + + #endregion + } +} diff --git a/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs b/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs new file mode 100644 index 0000000..239cda3 --- /dev/null +++ b/src/rhino-plugin/RhinoMCP/GrasshopperHandler.cs @@ -0,0 +1,527 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Linq; +using Grasshopper; +using Grasshopper.Kernel; +using Grasshopper.Kernel.Parameters; +using Grasshopper.Kernel.Special; +using Newtonsoft.Json.Linq; +using Rhino; + +namespace RhinoMCP +{ + /// + /// Handles all Grasshopper-related commands from the MCP Server. + /// + public static class GrasshopperHandler + { + public static object Execute(string action, JObject parameters) + { + return action switch + { + "grasshopper_open_definition" => OpenDefinition(parameters), + "grasshopper_get_definition_info" => GetDefinitionInfo(), + "grasshopper_set_slider_value" => SetSliderValue(parameters), + "grasshopper_set_toggle_value" => SetToggleValue(parameters), + "grasshopper_set_panel_text" => SetPanelText(parameters), + "grasshopper_add_component" => AddComponent(parameters), + "grasshopper_connect_components" => ConnectComponents(parameters), + "grasshopper_add_slider" => AddSlider(parameters), + "grasshopper_bake" => BakeGeometry(parameters), + "grasshopper_save_definition" => SaveDefinition(parameters), + "grasshopper_recompute" => Recompute(), + _ => new { error = $"Unknown Grasshopper action: {action}" } + }; + } + + private static GH_Document? GetActiveDocument() + { + var editor = Instances.ActiveCanvas?.Document; + return editor; + } + + private static object OpenDefinition(JObject p) + { + var filePath = p["file_path"]?.ToString() ?? ""; + if (string.IsNullOrEmpty(filePath)) + return new { error = "File path required" }; + + try + { + // Ensure Grasshopper is open + if (Instances.ActiveCanvas == null) + { + RhinoApp.RunScript("_Grasshopper", false); + System.Threading.Thread.Sleep(1000); // Wait for GH to open + } + + var io = new GH_DocumentIO(); + if (!io.Open(filePath)) + return new { error = $"Failed to open file: {filePath}" }; + + var doc = io.Document; + if (doc == null) + return new { error = "Failed to load document" }; + + Instances.ActiveCanvas.Document = doc; + Instances.ActiveCanvas.Refresh(); + + return new + { + success = true, + file_path = filePath, + component_count = doc.ObjectCount, + name = doc.DisplayName + }; + } + catch (Exception ex) + { + return new { error = ex.Message }; + } + } + + private static object GetDefinitionInfo() + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var components = new List(); + var sliders = new List(); + var toggles = new List(); + var panels = new List(); + + foreach (var obj in doc.Objects) + { + var info = new Dictionary + { + ["instance_guid"] = obj.InstanceGuid.ToString(), + ["name"] = obj.Name ?? "", + ["nickname"] = obj.NickName ?? "", + ["category"] = obj.Category ?? "", + ["subcategory"] = obj.SubCategory ?? "", + ["position"] = new { x = obj.Attributes.Pivot.X, y = obj.Attributes.Pivot.Y } + }; + + if (obj is GH_NumberSlider slider) + { + sliders.Add(new + { + info["instance_guid"], + info["nickname"], + info["position"], + current_value = slider.CurrentValue, + min_value = (double)slider.Slider.Minimum, + max_value = (double)slider.Slider.Maximum, + type = slider.Slider.Type.ToString() + }); + } + else if (obj is GH_BooleanToggle toggle) + { + toggles.Add(new + { + info["instance_guid"], + info["nickname"], + info["position"], + value = toggle.Value + }); + } + else if (obj is GH_Panel panel) + { + panels.Add(new + { + info["instance_guid"], + info["nickname"], + info["position"], + text = panel.UserText + }); + } + else if (obj is IGH_Component component) + { + var inputs = component.Params.Input.Select(p => new + { + name = p.Name, + nickname = p.NickName, + type = p.TypeName, + source_count = p.SourceCount + }).ToList(); + + var outputs = component.Params.Output.Select(p => new + { + name = p.Name, + nickname = p.NickName, + type = p.TypeName, + recipient_count = p.Recipients.Count + }).ToList(); + + components.Add(new + { + info["instance_guid"], + info["name"], + info["nickname"], + info["category"], + info["subcategory"], + info["position"], + inputs, + outputs + }); + } + } + + return new + { + name = doc.DisplayName, + file_path = doc.FilePath ?? "(unsaved)", + object_count = doc.ObjectCount, + components, + sliders, + toggles, + panels + }; + } + + private static object SetSliderValue(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var sliderName = p["slider_name"]?.ToString() ?? ""; + var value = p["value"]?.Value() ?? 0; + + var slider = doc.Objects + .OfType() + .FirstOrDefault(s => s.NickName == sliderName || s.Name == sliderName); + + if (slider == null) + return new { error = $"Slider '{sliderName}' not found" }; + + // Clamp value to slider range + var min = (double)slider.Slider.Minimum; + var max = (double)slider.Slider.Maximum; + value = Math.Max(min, Math.Min(max, value)); + + slider.SetSliderValue((decimal)value); + slider.ExpireSolution(true); + + return new { success = true, slider = sliderName, value, min, max }; + } + + private static object SetToggleValue(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var toggleName = p["toggle_name"]?.ToString() ?? ""; + var value = p["value"]?.Value() ?? false; + + var toggle = doc.Objects + .OfType() + .FirstOrDefault(t => t.NickName == toggleName || t.Name == toggleName); + + if (toggle == null) + return new { error = $"Toggle '{toggleName}' not found" }; + + toggle.Value = value; + toggle.ExpireSolution(true); + + return new { success = true, toggle = toggleName, value }; + } + + private static object SetPanelText(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var panelName = p["panel_name"]?.ToString() ?? ""; + var text = p["text"]?.ToString() ?? ""; + + var panel = doc.Objects + .OfType() + .FirstOrDefault(pnl => pnl.NickName == panelName || pnl.Name == panelName); + + if (panel == null) + return new { error = $"Panel '{panelName}' not found" }; + + panel.UserText = text; + panel.ExpireSolution(true); + + return new { success = true, panel = panelName, text }; + } + + private static object AddComponent(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + { + // Try to open Grasshopper + RhinoApp.RunScript("_Grasshopper", false); + System.Threading.Thread.Sleep(1000); + doc = GetActiveDocument(); + if (doc == null) + return new { error = "Could not open Grasshopper" }; + } + + var componentName = p["component_name"]?.ToString() ?? ""; + var position = p["position"] as JObject; + var x = position?["x"]?.Value() ?? 0; + var y = position?["y"]?.Value() ?? 0; + + // Find component by name in the component server + var proxy = Instances.ComponentServer.FindObjectByName(componentName, true, true); + if (proxy == null) + return new { error = $"Component '{componentName}' not found" }; + + try + { + var obj = proxy.CreateInstance() as IGH_DocumentObject; + if (obj == null) + return new { error = $"Could not create instance of '{componentName}'" }; + + obj.CreateAttributes(); + obj.Attributes.Pivot = new PointF(x, y); + + doc.AddObject(obj, false); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + component = componentName, + instance_guid = obj.InstanceGuid.ToString(), + position = new { x, y } + }; + } + catch (Exception ex) + { + return new { error = ex.Message }; + } + } + + private static object ConnectComponents(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var sourceId = p["source_component"]?.ToString() ?? ""; + var sourceOutput = p["source_output"]?.ToString() ?? ""; + var targetId = p["target_component"]?.ToString() ?? ""; + var targetInput = p["target_input"]?.ToString() ?? ""; + + // Find source component + var sourceObj = FindComponent(doc, sourceId); + if (sourceObj == null) + return new { error = $"Source component '{sourceId}' not found" }; + + // Find target component + var targetObj = FindComponent(doc, targetId); + if (targetObj == null) + return new { error = $"Target component '{targetId}' not found" }; + + // Get output parameter + IGH_Param? outputParam = null; + if (sourceObj is IGH_Component sourceComp) + { + outputParam = sourceComp.Params.Output.FirstOrDefault(o => + o.Name == sourceOutput || o.NickName == sourceOutput); + } + else if (sourceObj is IGH_Param param) + { + outputParam = param; + } + + if (outputParam == null) + return new { error = $"Output '{sourceOutput}' not found on source component" }; + + // Get input parameter + IGH_Param? inputParam = null; + if (targetObj is IGH_Component targetComp) + { + inputParam = targetComp.Params.Input.FirstOrDefault(i => + i.Name == targetInput || i.NickName == targetInput); + } + else if (targetObj is IGH_Param param) + { + inputParam = param; + } + + if (inputParam == null) + return new { error = $"Input '{targetInput}' not found on target component" }; + + // Create connection + inputParam.AddSource(outputParam); + doc.NewSolution(true); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + connection = new + { + source = new { component = sourceId, output = sourceOutput }, + target = new { component = targetId, input = targetInput } + } + }; + } + + private static IGH_DocumentObject? FindComponent(GH_Document doc, string identifier) + { + // Try to find by GUID + if (Guid.TryParse(identifier, out var guid)) + { + return doc.Objects.FirstOrDefault(o => o.InstanceGuid == guid); + } + + // Try to find by name or nickname + return doc.Objects.FirstOrDefault(o => + o.Name == identifier || o.NickName == identifier); + } + + private static object AddSlider(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + { + RhinoApp.RunScript("_Grasshopper", false); + System.Threading.Thread.Sleep(1000); + doc = GetActiveDocument(); + if (doc == null) + return new { error = "Could not open Grasshopper" }; + } + + var name = p["name"]?.ToString() ?? "Slider"; + var minValue = p["min_value"]?.Value() ?? 0; + var maxValue = p["max_value"]?.Value() ?? 100; + var currentValue = p["current_value"]?.Value() ?? 50; + var position = p["position"] as JObject; + var x = position?["x"]?.Value() ?? 0; + var y = position?["y"]?.Value() ?? 0; + + var slider = new GH_NumberSlider(); + slider.CreateAttributes(); + slider.Attributes.Pivot = new PointF(x, y); + slider.NickName = name; + slider.Slider.Minimum = minValue; + slider.Slider.Maximum = maxValue; + slider.SetSliderValue(currentValue); + + doc.AddObject(slider, false); + Instances.ActiveCanvas?.Refresh(); + + return new + { + success = true, + slider = name, + instance_guid = slider.InstanceGuid.ToString(), + min = minValue, + max = maxValue, + value = currentValue + }; + } + + private static object BakeGeometry(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var componentName = p["component_name"]?.ToString(); + var layerName = p["layer_name"]?.ToString() ?? "Baked"; + + var rhinoDoc = RhinoDoc.ActiveDoc; + if (rhinoDoc == null) + return new { error = "No active Rhino document" }; + + // Ensure layer exists + var layerIndex = rhinoDoc.Layers.FindByFullPath(layerName, -1); + if (layerIndex < 0) + layerIndex = rhinoDoc.Layers.Add(layerName, Color.Black); + + var bakedCount = 0; + + foreach (var obj in doc.Objects) + { + if (obj is IGH_Component component) + { + // Skip if we're looking for a specific component + if (!string.IsNullOrEmpty(componentName) && + component.Name != componentName && + component.NickName != componentName) + continue; + + foreach (var output in component.Params.Output) + { + foreach (var item in output.VolatileData.AllData(true)) + { + if (item is Grasshopper.Kernel.Types.IGH_BakeAwareData bakeAware) + { + var attributes = new Rhino.DocObjects.ObjectAttributes + { + LayerIndex = layerIndex + }; + + Guid objId; + if (bakeAware.BakeGeometry(rhinoDoc, attributes, out objId)) + bakedCount++; + } + } + } + } + } + + rhinoDoc.Views.Redraw(); + + return new { success = true, baked_count = bakedCount, layer = layerName }; + } + + private static object SaveDefinition(JObject p) + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + var filePath = p["file_path"]?.ToString(); + + try + { + var io = new GH_DocumentIO(doc); + bool success; + + if (string.IsNullOrEmpty(filePath)) + { + if (string.IsNullOrEmpty(doc.FilePath)) + return new { error = "No file path specified and document has no existing path" }; + + success = io.Save(); + filePath = doc.FilePath; + } + else + { + success = io.SaveAs(filePath); + } + + return new { success, file_path = filePath }; + } + catch (Exception ex) + { + return new { error = ex.Message }; + } + } + + private static object Recompute() + { + var doc = GetActiveDocument(); + if (doc == null) + return new { error = "No active Grasshopper document" }; + + doc.NewSolution(true); + Instances.ActiveCanvas?.Refresh(); + + return new { success = true, message = "Definition recomputed" }; + } + } +} diff --git a/src/rhino-plugin/RhinoMCP/HttpServer.cs b/src/rhino-plugin/RhinoMCP/HttpServer.cs new file mode 100644 index 0000000..9f2db4d --- /dev/null +++ b/src/rhino-plugin/RhinoMCP/HttpServer.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Rhino; + +namespace RhinoMCP +{ + /// + /// HTTP Server that receives commands from the MCP Server. + /// + public class HttpServer : IDisposable + { + private readonly int _port; + private WebServer? _server; + + public HttpServer(int port) + { + _port = port; + } + + public void Start() + { + _server = new WebServer(o => o + .WithUrlPrefix($"http://localhost:{_port}/") + .WithMode(HttpListenerMode.EmbedIO)) + .WithCors() + .WithWebApi("/", m => m.WithController()); + + _server.StateChanged += (s, e) => + { + RhinoApp.WriteLine($"RhinoMCP: Server state changed to {e.NewState}"); + }; + + _server.RunAsync(); + } + + public void Stop() + { + _server?.Dispose(); + } + + public void Dispose() + { + Stop(); + } + } + + /// + /// Web API Controller handling all Rhino/Grasshopper commands. + /// + public class RhinoApiController : WebApiController + { + [Route(HttpVerbs.Get, "/health")] + public object GetHealth() + { + return new { status = "ok", version = "1.0.0", rhino_version = RhinoApp.Version.ToString() }; + } + + [Route(HttpVerbs.Post, "/execute")] + public async Task Execute() + { + try + { + var body = await HttpContext.GetRequestBodyAsStringAsync(); + var request = JsonConvert.DeserializeObject(body); + + if (request == null) + return new { error = "Invalid request body" }; + + var action = request["action"]?.ToString(); + var parameters = request["params"] as JObject ?? new JObject(); + + if (string.IsNullOrEmpty(action)) + return new { error = "Missing action parameter" }; + + // Execute on Rhino's main thread + object? result = null; + Exception? exception = null; + + RhinoApp.InvokeOnUiThread(new Action(() => + { + try + { + result = CommandHandler.Execute(action, parameters); + } + catch (Exception ex) + { + exception = ex; + } + })); + + // Wait a bit for UI thread execution + await Task.Delay(100); + + if (exception != null) + return new { error = exception.Message }; + + return result ?? new { success = true }; + } + catch (Exception ex) + { + return new { error = ex.Message }; + } + } + } +} diff --git a/src/rhino-plugin/RhinoMCP/RhinoMCP.csproj b/src/rhino-plugin/RhinoMCP/RhinoMCP.csproj new file mode 100644 index 0000000..0955c2f --- /dev/null +++ b/src/rhino-plugin/RhinoMCP/RhinoMCP.csproj @@ -0,0 +1,33 @@ + + + + net7.0-windows + enable + enable + latest + + + true + true + + + RhinoMCP + RhinoMCP + 1.0.0 + RhinoMCP Contributors + MCP Server integration for Rhino and Grasshopper + + + + + + + + + + + + + + + diff --git a/src/rhino-plugin/RhinoMCP/RhinoMcpPlugin.cs b/src/rhino-plugin/RhinoMCP/RhinoMcpPlugin.cs new file mode 100644 index 0000000..a5aac90 --- /dev/null +++ b/src/rhino-plugin/RhinoMCP/RhinoMcpPlugin.cs @@ -0,0 +1,54 @@ +using System; +using Rhino; +using Rhino.PlugIns; + +namespace RhinoMCP +{ + /// + /// RhinoMCP Plugin - Provides HTTP API for Claude AI integration. + /// + public class RhinoMcpPlugin : PlugIn + { + private HttpServer? _httpServer; + + public static RhinoMcpPlugin? Instance { get; private set; } + + public RhinoMcpPlugin() + { + Instance = this; + } + + protected override LoadReturnCode OnLoad(ref string errorMessage) + { + RhinoApp.WriteLine("RhinoMCP: Plugin loading..."); + + try + { + // Start HTTP server on port 9000 + _httpServer = new HttpServer(9000); + _httpServer.Start(); + + RhinoApp.WriteLine("RhinoMCP: HTTP server started on http://localhost:9000"); + RhinoApp.WriteLine("RhinoMCP: Ready for Claude AI connections!"); + + return LoadReturnCode.Success; + } + catch (Exception ex) + { + errorMessage = $"RhinoMCP: Failed to start HTTP server: {ex.Message}"; + RhinoApp.WriteLine(errorMessage); + return LoadReturnCode.ErrorShowDialog; + } + } + + protected override void OnShutdown() + { + RhinoApp.WriteLine("RhinoMCP: Plugin shutting down..."); + + _httpServer?.Stop(); + _httpServer?.Dispose(); + + base.OnShutdown(); + } + } +}