rhinomcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: rhinomcp
3
+ Version: 0.1.0
4
+ Summary: Rhino integration through the Model Context Protocol
5
+ Author-email: Jingcheng Chen <mail@jchen.ch>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jingcheng-chen/rhinomcp
8
+ Project-URL: Bug Tracker, https://github.com/jingcheng-chen/rhinomcp/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: mcp[cli]>=1.3.0
@@ -0,0 +1,32 @@
1
+ [project]
2
+ name = "rhinomcp"
3
+ version = "0.1.0"
4
+ description = "Rhino integration through the Model Context Protocol"
5
+ readme = "README.md"
6
+ requires-python = ">=3.10"
7
+ authors = [
8
+ {name = "Jingcheng Chen", email = "mail@jchen.ch"}
9
+ ]
10
+ license = {text = "MIT"}
11
+ classifiers = [
12
+ "Programming Language :: Python :: 3",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ ]
16
+ dependencies = [
17
+ "mcp[cli]>=1.3.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ rhinomcp = "src.server:main"
22
+
23
+ [build-system]
24
+ requires = ["setuptools>=61.0", "wheel"]
25
+ build-backend = "setuptools.build_meta"
26
+
27
+ [tool.setuptools]
28
+ package-dir = {"" = "src"}
29
+
30
+ [project.urls]
31
+ "Homepage" = "https://github.com/jingcheng-chen/rhinomcp"
32
+ "Bug Tracker" = "https://github.com/jingcheng-chen/rhinomcp/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """Rhino integration through the Model Context Protocol."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ # Expose key classes and functions for easier imports
6
+ from .server import RhinoConnection, get_rhino_connection
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: rhinomcp
3
+ Version: 0.1.0
4
+ Summary: Rhino integration through the Model Context Protocol
5
+ Author-email: Jingcheng Chen <mail@jchen.ch>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jingcheng-chen/rhinomcp
8
+ Project-URL: Bug Tracker, https://github.com/jingcheng-chen/rhinomcp/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: mcp[cli]>=1.3.0
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ src/__init__.py
3
+ src/server.py
4
+ src/rhinomcp.egg-info/PKG-INFO
5
+ src/rhinomcp.egg-info/SOURCES.txt
6
+ src/rhinomcp.egg-info/dependency_links.txt
7
+ src/rhinomcp.egg-info/entry_points.txt
8
+ src/rhinomcp.egg-info/requires.txt
9
+ src/rhinomcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rhinomcp = src.server:main
@@ -0,0 +1 @@
1
+ mcp[cli]>=1.3.0
@@ -0,0 +1,2 @@
1
+ __init__
2
+ server
@@ -0,0 +1,435 @@
1
+ # rhino_mcp_server.py
2
+ from mcp.server.fastmcp import FastMCP, Context, Image
3
+ import socket
4
+ import json
5
+ import asyncio
6
+ import logging
7
+ from dataclasses import dataclass
8
+ from contextlib import asynccontextmanager
9
+ from typing import AsyncIterator, Dict, Any, List
10
+ import os
11
+ from pathlib import Path
12
+ import base64
13
+ from urllib.parse import urlparse
14
+
15
+ # Configure logging
16
+ logging.basicConfig(level=logging.INFO,
17
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
18
+ logger = logging.getLogger("RhinoMCPServer")
19
+
20
+ @dataclass
21
+ class RhinoConnection:
22
+ host: str
23
+ port: int
24
+ sock: socket.socket = None # Changed from 'socket' to 'sock' to avoid naming conflict
25
+
26
+ def connect(self) -> bool:
27
+ """Connect to the Rhino addon socket server"""
28
+ if self.sock:
29
+ return True
30
+
31
+ try:
32
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
33
+ self.sock.connect((self.host, self.port))
34
+ logger.info(f"Connected to Rhino at {self.host}:{self.port}")
35
+ return True
36
+ except Exception as e:
37
+ logger.error(f"Failed to connect to Rhino: {str(e)}")
38
+ self.sock = None
39
+ return False
40
+
41
+ def disconnect(self):
42
+ """Disconnect from the Rhino addon"""
43
+ if self.sock:
44
+ try:
45
+ self.sock.close()
46
+ except Exception as e:
47
+ logger.error(f"Error disconnecting from Rhino: {str(e)}")
48
+ finally:
49
+ self.sock = None
50
+
51
+ def receive_full_response(self, sock, buffer_size=8192):
52
+ """Receive the complete response, potentially in multiple chunks"""
53
+ chunks = []
54
+ # Use a consistent timeout value that matches the addon's timeout
55
+ sock.settimeout(15.0) # Match the addon's timeout
56
+
57
+ try:
58
+ while True:
59
+ try:
60
+ chunk = sock.recv(buffer_size)
61
+ if not chunk:
62
+ # If we get an empty chunk, the connection might be closed
63
+ if not chunks: # If we haven't received anything yet, this is an error
64
+ raise Exception("Connection closed before receiving any data")
65
+ break
66
+
67
+ chunks.append(chunk)
68
+
69
+ # Check if we've received a complete JSON object
70
+ try:
71
+ data = b''.join(chunks)
72
+ json.loads(data.decode('utf-8'))
73
+ # If we get here, it parsed successfully
74
+ logger.info(f"Received complete response ({len(data)} bytes)")
75
+ return data
76
+ except json.JSONDecodeError:
77
+ # Incomplete JSON, continue receiving
78
+ continue
79
+ except socket.timeout:
80
+ # If we hit a timeout during receiving, break the loop and try to use what we have
81
+ logger.warning("Socket timeout during chunked receive")
82
+ break
83
+ except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
84
+ logger.error(f"Socket connection error during receive: {str(e)}")
85
+ raise # Re-raise to be handled by the caller
86
+ except socket.timeout:
87
+ logger.warning("Socket timeout during chunked receive")
88
+ except Exception as e:
89
+ logger.error(f"Error during receive: {str(e)}")
90
+ raise
91
+
92
+ # If we get here, we either timed out or broke out of the loop
93
+ # Try to use what we have
94
+ if chunks:
95
+ data = b''.join(chunks)
96
+ logger.info(f"Returning data after receive completion ({len(data)} bytes)")
97
+ try:
98
+ # Try to parse what we have
99
+ json.loads(data.decode('utf-8'))
100
+ return data
101
+ except json.JSONDecodeError:
102
+ # If we can't parse it, it's incomplete
103
+ raise Exception("Incomplete JSON response received")
104
+ else:
105
+ raise Exception("No data received")
106
+
107
+ def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
108
+ """Send a command to Rhino and return the response"""
109
+ if not self.sock and not self.connect():
110
+ raise ConnectionError("Not connected to Rhino")
111
+
112
+ command = {
113
+ "type": command_type,
114
+ "params": params or {}
115
+ }
116
+
117
+ try:
118
+ # Log the command being sent
119
+ logger.info(f"Sending command: {command_type} with params: {params}")
120
+
121
+ # Send the command
122
+ self.sock.sendall(json.dumps(command).encode('utf-8'))
123
+ logger.info(f"Command sent, waiting for response...")
124
+
125
+ # Set a timeout for receiving - use the same timeout as in receive_full_response
126
+ self.sock.settimeout(15.0) # Match the addon's timeout
127
+
128
+ # Receive the response using the improved receive_full_response method
129
+ response_data = self.receive_full_response(self.sock)
130
+ logger.info(f"Received {len(response_data)} bytes of data")
131
+
132
+ response = json.loads(response_data.decode('utf-8'))
133
+ logger.info(f"Response parsed, status: {response.get('status', 'unknown')}")
134
+
135
+ if response.get("status") == "error":
136
+ logger.error(f"Rhino error: {response.get('message')}")
137
+ raise Exception(response.get("message", "Unknown error from Rhino"))
138
+
139
+ return response.get("result", {})
140
+ except socket.timeout:
141
+ logger.error("Socket timeout while waiting for response from Rhino")
142
+ # Don't try to reconnect here - let the get_rhino_connection handle reconnection
143
+ # Just invalidate the current socket so it will be recreated next time
144
+ self.sock = None
145
+ raise Exception("Timeout waiting for Rhino response - try simplifying your request")
146
+ except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
147
+ logger.error(f"Socket connection error: {str(e)}")
148
+ self.sock = None
149
+ raise Exception(f"Connection to Rhino lost: {str(e)}")
150
+ except json.JSONDecodeError as e:
151
+ logger.error(f"Invalid JSON response from Rhino: {str(e)}")
152
+ # Try to log what was received
153
+ if 'response_data' in locals() and response_data:
154
+ logger.error(f"Raw response (first 200 bytes): {response_data[:200]}")
155
+ raise Exception(f"Invalid response from Rhino: {str(e)}")
156
+ except Exception as e:
157
+ logger.error(f"Error communicating with Rhino: {str(e)}")
158
+ # Don't try to reconnect here - let the get_rhino_connection handle reconnection
159
+ self.sock = None
160
+ raise Exception(f"Communication error with Rhino: {str(e)}")
161
+
162
+ @asynccontextmanager
163
+ async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
164
+ """Manage server startup and shutdown lifecycle"""
165
+ # We don't need to create a connection here since we're using the global connection
166
+ # for resources and tools
167
+
168
+ try:
169
+ # Just log that we're starting up
170
+ logger.info("RhinoMCP server starting up")
171
+
172
+ # Try to connect to Rhino on startup to verify it's available
173
+ try:
174
+ # This will initialize the global connection if needed
175
+ rhino = get_rhino_connection()
176
+ logger.info("Successfully connected to Rhino on startup")
177
+ except Exception as e:
178
+ logger.warning(f"Could not connect to Rhino on startup: {str(e)}")
179
+ logger.warning("Make sure the Rhino addon is running before using Rhino resources or tools")
180
+
181
+ # Return an empty context - we're using the global connection
182
+ yield {}
183
+ finally:
184
+ # Clean up the global connection on shutdown
185
+ global _rhino_connection
186
+ if _rhino_connection:
187
+ logger.info("Disconnecting from Rhino on shutdown")
188
+ _rhino_connection.disconnect()
189
+ _rhino_connection = None
190
+ logger.info("RhinoMCP server shut down")
191
+
192
+ # Create the MCP server with lifespan support
193
+ mcp = FastMCP(
194
+ "RhinoMCP",
195
+ description="Rhino integration through the Model Context Protocol",
196
+ lifespan=server_lifespan
197
+ )
198
+
199
+ # Resource endpoints
200
+
201
+ # Global connection for resources (since resources can't access context)
202
+ _rhino_connection = None
203
+ _polyhaven_enabled = False # Add this global variable
204
+
205
+ def get_rhino_connection():
206
+ """Get or create a persistent Rhino connection"""
207
+ global _rhino_connection, _polyhaven_enabled # Add _polyhaven_enabled to globals
208
+
209
+ # If we have an existing connection, check if it's still valid
210
+ if _rhino_connection is not None:
211
+ try:
212
+ # First check if PolyHaven is enabled by sending a ping command
213
+ result = _rhino_connection.send_command("get_polyhaven_status")
214
+ # Store the PolyHaven status globally
215
+ _polyhaven_enabled = result.get("enabled", False)
216
+ return _rhino_connection
217
+ except Exception as e:
218
+ # Connection is dead, close it and create a new one
219
+ logger.warning(f"Existing connection is no longer valid: {str(e)}")
220
+ try:
221
+ _rhino_connection.disconnect()
222
+ except:
223
+ pass
224
+ _rhino_connection = None
225
+
226
+ # Create a new connection if needed
227
+ if _rhino_connection is None:
228
+ _rhino_connection = RhinoConnection(host="127.0.0.1", port=1999)
229
+ if not _rhino_connection.connect():
230
+ logger.error("Failed to connect to Rhino")
231
+ _rhino_connection = None
232
+ raise Exception("Could not connect to Rhino. Make sure the Rhino addon is running.")
233
+ logger.info("Created new persistent connection to Rhino")
234
+
235
+ return _rhino_connection
236
+
237
+
238
+ @mcp.tool()
239
+ def get_scene_info(ctx: Context) -> str:
240
+ """Get detailed information about the current Rhino scene"""
241
+ try:
242
+ rhino = get_rhino_connection()
243
+ result = rhino.send_command("get_scene_info")
244
+
245
+ # Just return the JSON representation of what Rhino sent us
246
+ return json.dumps(result, indent=2)
247
+ except Exception as e:
248
+ logger.error(f"Error getting scene info from Rhino: {str(e)}")
249
+ return f"Error getting scene info: {str(e)}"
250
+
251
+ @mcp.tool()
252
+ def get_object_info(ctx: Context, object_name: str) -> str:
253
+ """
254
+ Get detailed information about a specific object in the Rhino scene.
255
+
256
+ Parameters:
257
+ - object_name: The name of the object to get information about
258
+ """
259
+ try:
260
+ rhino = get_rhino_connection()
261
+ result = rhino.send_command("get_object_info", {"name": object_name})
262
+
263
+ # Just return the JSON representation of what Rhino sent us
264
+ return json.dumps(result, indent=2)
265
+ except Exception as e:
266
+ logger.error(f"Error getting object info from Rhino: {str(e)}")
267
+ return f"Error getting object info: {str(e)}"
268
+
269
+
270
+
271
+ @mcp.tool()
272
+ def create_object(
273
+ ctx: Context,
274
+ type: str = "CUBE",
275
+ name: str = None,
276
+ location: List[float] = None,
277
+ rotation: List[float] = None,
278
+ scale: List[float] = None,
279
+ # Torus-specific parameters
280
+ align: str = "WORLD",
281
+ major_segments: int = 48,
282
+ minor_segments: int = 12,
283
+ mode: str = "MAJOR_MINOR",
284
+ major_radius: float = 1.0,
285
+ minor_radius: float = 0.25,
286
+ abso_major_rad: float = 1.25,
287
+ abso_minor_rad: float = 0.75,
288
+ generate_uvs: bool = True
289
+ ) -> str:
290
+ """
291
+ Create a new object in the Rhino scene.
292
+
293
+ Parameters:
294
+ - type: Object type (CUBE, SPHERE, CYLINDER, PLANE, CONE, TORUS, EMPTY, CAMERA, LIGHT)
295
+ - name: Optional name for the object
296
+ - location: Optional [x, y, z] location coordinates
297
+ - rotation: Optional [x, y, z] rotation in radians
298
+ - scale: Optional [x, y, z] scale factors (not used for TORUS)
299
+
300
+ Torus-specific parameters (only used when type == "TORUS"):
301
+ - align: How to align the torus ('WORLD', 'VIEW', or 'CURSOR')
302
+ - major_segments: Number of segments for the main ring
303
+ - minor_segments: Number of segments for the cross-section
304
+ - mode: Dimension mode ('MAJOR_MINOR' or 'EXT_INT')
305
+ - major_radius: Radius from the origin to the center of the cross sections
306
+ - minor_radius: Radius of the torus' cross section
307
+ - abso_major_rad: Total exterior radius of the torus
308
+ - abso_minor_rad: Total interior radius of the torus
309
+ - generate_uvs: Whether to generate a default UV map
310
+
311
+ Returns:
312
+ A message indicating the created object name.
313
+ """
314
+ try:
315
+ # Get the global connection
316
+ rhino = get_rhino_connection()
317
+
318
+ # Set default values for missing parameters
319
+ loc = location or [0, 0, 0]
320
+ rot = rotation or [0, 0, 0]
321
+ sc = scale or [1, 1, 1]
322
+
323
+ params = {
324
+ "type": type,
325
+ "location": loc,
326
+ "rotation": rot,
327
+ }
328
+
329
+ if name:
330
+ params["name"] = name
331
+
332
+ if type == "TORUS":
333
+ # For torus, the scale is not used.
334
+ params.update({
335
+ "align": align,
336
+ "major_segments": major_segments,
337
+ "minor_segments": minor_segments,
338
+ "mode": mode,
339
+ "major_radius": major_radius,
340
+ "minor_radius": minor_radius,
341
+ "abso_major_rad": abso_major_rad,
342
+ "abso_minor_rad": abso_minor_rad,
343
+ "generate_uvs": generate_uvs
344
+ })
345
+ result = rhino.send_command("create_object", params)
346
+ return f"Created {type} object: {result['name']}"
347
+ else:
348
+ # For non-torus objects, include scale
349
+ params["scale"] = sc
350
+ result = rhino.send_command("create_object", params)
351
+ return f"Created {type} object: {result['name']}"
352
+ except Exception as e:
353
+ logger.error(f"Error creating object: {str(e)}")
354
+ return f"Error creating object: {str(e)}"
355
+
356
+ @mcp.tool()
357
+ def modify_object(
358
+ ctx: Context,
359
+ name: str,
360
+ location: List[float] = None,
361
+ rotation: List[float] = None,
362
+ scale: List[float] = None,
363
+ visible: bool = None
364
+ ) -> str:
365
+ """
366
+ Modify an existing object in the Rhino scene.
367
+
368
+ Parameters:
369
+ - name: Name of the object to modify
370
+ - location: Optional [x, y, z] location coordinates
371
+ - rotation: Optional [x, y, z] rotation in radians
372
+ - scale: Optional [x, y, z] scale factors
373
+ - visible: Optional boolean to set visibility
374
+ """
375
+ try:
376
+ # Get the global connection
377
+ rhino = get_rhino_connection()
378
+
379
+ params = {"name": name}
380
+
381
+ if location is not None:
382
+ params["location"] = location
383
+ if rotation is not None:
384
+ params["rotation"] = rotation
385
+ if scale is not None:
386
+ params["scale"] = scale
387
+ if visible is not None:
388
+ params["visible"] = visible
389
+
390
+ result = rhino.send_command("modify_object", params)
391
+ return f"Modified object: {result['name']}"
392
+ except Exception as e:
393
+ logger.error(f"Error modifying object: {str(e)}")
394
+ return f"Error modifying object: {str(e)}"
395
+
396
+ @mcp.tool()
397
+ def delete_object(ctx: Context, name: str) -> str:
398
+ """
399
+ Delete an object from the Rhino scene.
400
+
401
+ Parameters:
402
+ - name: Name of the object to delete
403
+ """
404
+ try:
405
+ # Get the global connection
406
+ rhino = get_rhino_connection()
407
+
408
+ result = rhino.send_command("delete_object", {"name": name})
409
+ return f"Deleted object: {name}"
410
+ except Exception as e:
411
+ logger.error(f"Error deleting object: {str(e)}")
412
+ return f"Error deleting object: {str(e)}"
413
+
414
+ @mcp.prompt()
415
+ def asset_creation_strategy() -> str:
416
+ """Defines the preferred strategy for creating assets in Rhino"""
417
+ return """When creating 3D content in Rhino, always start by checking if integrations are available:
418
+
419
+ 0. Before anything, always check the scene from get_scene_info()
420
+ 1. Use the method create_object() for basic primitives (CUBE, SPHERE, etc.)
421
+ 2. When including an object into scene, ALWAYS make sure that the name of the object is meanful.
422
+ 3. After giving the tool location/scale/rotation information (via create_object() and modify_object()),
423
+ double check the related object's location, scale, rotation, and world_bounding_box using get_object_info(),
424
+ so that the object is in the desired location.
425
+ """
426
+
427
+ # Main execution
428
+
429
+ def main():
430
+ """Run the MCP server"""
431
+ mcp.run()
432
+
433
+
434
+ if __name__ == "__main__":
435
+ main()