Skip to main content
Glama

SketchupMCP

by mhyrr
from mcp.server.fastmcp import FastMCP, Context import socket import json import asyncio import logging from dataclasses import dataclass from contextlib import asynccontextmanager from typing import AsyncIterator, Dict, Any, List # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger("SketchupMCPServer") # Define version directly to avoid pkg_resources dependency __version__ = "0.1.17" logger.info(f"SketchupMCP Server version {__version__} starting up") @dataclass class SketchupConnection: host: str port: int sock: socket.socket = None def connect(self) -> bool: """Connect to the Sketchup extension socket server""" if self.sock: try: # Test if connection is still alive self.sock.settimeout(0.1) self.sock.send(b'') return True except (socket.error, BrokenPipeError, ConnectionResetError): # Connection is dead, close it and reconnect logger.info("Connection test failed, reconnecting...") self.disconnect() try: self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.sock.connect((self.host, self.port)) logger.info(f"Connected to Sketchup at {self.host}:{self.port}") return True except Exception as e: logger.error(f"Failed to connect to Sketchup: {str(e)}") self.sock = None return False def disconnect(self): """Disconnect from the Sketchup extension""" if self.sock: try: self.sock.close() except Exception as e: logger.error(f"Error disconnecting from Sketchup: {str(e)}") finally: self.sock = None def receive_full_response(self, sock, buffer_size=8192): """Receive the complete response, potentially in multiple chunks""" chunks = [] sock.settimeout(15.0) try: while True: try: chunk = sock.recv(buffer_size) if not chunk: if not chunks: raise Exception("Connection closed before receiving any data") break chunks.append(chunk) try: data = b''.join(chunks) json.loads(data.decode('utf-8')) logger.info(f"Received complete response ({len(data)} bytes)") return data except json.JSONDecodeError: continue except socket.timeout: logger.warning("Socket timeout during chunked receive") break except (ConnectionError, BrokenPipeError, ConnectionResetError) as e: logger.error(f"Socket connection error during receive: {str(e)}") raise except socket.timeout: logger.warning("Socket timeout during chunked receive") except Exception as e: logger.error(f"Error during receive: {str(e)}") raise if chunks: data = b''.join(chunks) logger.info(f"Returning data after receive completion ({len(data)} bytes)") try: json.loads(data.decode('utf-8')) return data except json.JSONDecodeError: raise Exception("Incomplete JSON response received") else: raise Exception("No data received") def send_command(self, method: str, params: Dict[str, Any] = None, request_id: Any = None) -> Dict[str, Any]: """Send a JSON-RPC request to Sketchup and return the response""" # Try to connect if not connected if not self.connect(): raise ConnectionError("Not connected to Sketchup") # Ensure we're sending a proper JSON-RPC request if method == "tools/call" and params and "name" in params and "arguments" in params: # This is already in the correct format request = { "jsonrpc": "2.0", "method": method, "params": params, "id": request_id } else: # This is a direct command - convert to JSON-RPC command_name = method command_params = params or {} # Log the conversion logger.info(f"Converting direct command '{command_name}' to JSON-RPC format") request = { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": command_name, "arguments": command_params }, "id": request_id } # Maximum number of retries max_retries = 2 retry_count = 0 while retry_count <= max_retries: try: logger.info(f"Sending JSON-RPC request: {request}") # Log the exact bytes being sent request_bytes = json.dumps(request).encode('utf-8') + b'\n' logger.info(f"Raw bytes being sent: {request_bytes}") self.sock.sendall(request_bytes) logger.info(f"Request sent, waiting for response...") self.sock.settimeout(15.0) response_data = self.receive_full_response(self.sock) logger.info(f"Received {len(response_data)} bytes of data") response = json.loads(response_data.decode('utf-8')) logger.info(f"Response parsed: {response}") if "error" in response: logger.error(f"Sketchup error: {response['error']}") raise Exception(response["error"].get("message", "Unknown error from Sketchup")) return response.get("result", {}) except (socket.timeout, ConnectionError, BrokenPipeError, ConnectionResetError) as e: logger.warning(f"Connection error (attempt {retry_count+1}/{max_retries+1}): {str(e)}") retry_count += 1 if retry_count <= max_retries: logger.info(f"Retrying connection...") self.disconnect() if not self.connect(): logger.error("Failed to reconnect") break else: logger.error(f"Max retries reached, giving up") self.sock = None raise Exception(f"Connection to Sketchup lost after {max_retries+1} attempts: {str(e)}") except json.JSONDecodeError as e: logger.error(f"Invalid JSON response from Sketchup: {str(e)}") if 'response_data' in locals() and response_data: logger.error(f"Raw response (first 200 bytes): {response_data[:200]}") raise Exception(f"Invalid response from Sketchup: {str(e)}") except Exception as e: logger.error(f"Error communicating with Sketchup: {str(e)}") self.sock = None raise Exception(f"Communication error with Sketchup: {str(e)}") # Global connection management _sketchup_connection = None def get_sketchup_connection(): """Get or create a persistent Sketchup connection""" global _sketchup_connection if _sketchup_connection is not None: try: # Test connection with a ping command ping_request = { "jsonrpc": "2.0", "method": "ping", "params": {}, "id": 0 } _sketchup_connection.sock.sendall(json.dumps(ping_request).encode('utf-8') + b'\n') return _sketchup_connection except Exception as e: logger.warning(f"Existing connection is no longer valid: {str(e)}") try: _sketchup_connection.disconnect() except: pass _sketchup_connection = None if _sketchup_connection is None: _sketchup_connection = SketchupConnection(host="localhost", port=9876) if not _sketchup_connection.connect(): logger.error("Failed to connect to Sketchup") _sketchup_connection = None raise Exception("Could not connect to Sketchup. Make sure the Sketchup extension is running.") logger.info("Created new persistent connection to Sketchup") return _sketchup_connection @asynccontextmanager async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]: """Manage server startup and shutdown lifecycle""" try: logger.info("SketchupMCP server starting up") try: sketchup = get_sketchup_connection() logger.info("Successfully connected to Sketchup on startup") except Exception as e: logger.warning(f"Could not connect to Sketchup on startup: {str(e)}") logger.warning("Make sure the Sketchup extension is running") yield {} finally: global _sketchup_connection if _sketchup_connection: logger.info("Disconnecting from Sketchup") _sketchup_connection.disconnect() _sketchup_connection = None logger.info("SketchupMCP server shut down") # Create MCP server with lifespan support mcp = FastMCP( "SketchupMCP", description="Sketchup integration through the Model Context Protocol", lifespan=server_lifespan ) # Tool endpoints @mcp.tool() def create_component( ctx: Context, type: str = "cube", position: List[float] = None, dimensions: List[float] = None ) -> str: """Create a new component in Sketchup""" try: logger.info(f"create_component called with type={type}, position={position}, dimensions={dimensions}, request_id={ctx.request_id}") sketchup = get_sketchup_connection() params = { "name": "create_component", "arguments": { "type": type, "position": position or [0,0,0], "dimensions": dimensions or [1,1,1] } } logger.info(f"Calling send_command with method='tools/call', params={params}, request_id={ctx.request_id}") result = sketchup.send_command( method="tools/call", params=params, request_id=ctx.request_id ) logger.info(f"create_component result: {result}") return json.dumps(result) except Exception as e: logger.error(f"Error in create_component: {str(e)}") return f"Error creating component: {str(e)}" @mcp.tool() def delete_component( ctx: Context, id: str ) -> str: """Delete a component by ID""" try: sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "delete_component", "arguments": {"id": id} }, request_id=ctx.request_id ) return json.dumps(result) except Exception as e: return f"Error deleting component: {str(e)}" @mcp.tool() def transform_component( ctx: Context, id: str, position: List[float] = None, rotation: List[float] = None, scale: List[float] = None ) -> str: """Transform a component's position, rotation, or scale""" try: sketchup = get_sketchup_connection() arguments = {"id": id} if position is not None: arguments["position"] = position if rotation is not None: arguments["rotation"] = rotation if scale is not None: arguments["scale"] = scale result = sketchup.send_command( method="tools/call", params={ "name": "transform_component", "arguments": arguments }, request_id=ctx.request_id ) return json.dumps(result) except Exception as e: return f"Error transforming component: {str(e)}" @mcp.tool() def get_selection(ctx: Context) -> str: """Get currently selected components""" try: sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "get_selection", "arguments": {} }, request_id=ctx.request_id ) return json.dumps(result) except Exception as e: return f"Error getting selection: {str(e)}" @mcp.tool() def set_material( ctx: Context, id: str, material: str ) -> str: """Set material for a component""" try: sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "set_material", "arguments": { "id": id, "material": material } }, request_id=ctx.request_id ) return json.dumps(result) except Exception as e: return f"Error setting material: {str(e)}" @mcp.tool() def export_scene( ctx: Context, format: str = "skp" ) -> str: """Export the current scene""" try: sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "export", "arguments": { "format": format } }, request_id=ctx.request_id ) return json.dumps(result) except Exception as e: return f"Error exporting scene: {str(e)}" @mcp.tool() def create_mortise_tenon( ctx: Context, mortise_id: str, tenon_id: str, width: float = 1.0, height: float = 1.0, depth: float = 1.0, offset_x: float = 0.0, offset_y: float = 0.0, offset_z: float = 0.0 ) -> str: """Create a mortise and tenon joint between two components""" try: logger.info(f"create_mortise_tenon called with mortise_id={mortise_id}, tenon_id={tenon_id}, width={width}, height={height}, depth={depth}, offsets=({offset_x}, {offset_y}, {offset_z})") sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "create_mortise_tenon", "arguments": { "mortise_id": mortise_id, "tenon_id": tenon_id, "width": width, "height": height, "depth": depth, "offset_x": offset_x, "offset_y": offset_y, "offset_z": offset_z } }, request_id=ctx.request_id ) logger.info(f"create_mortise_tenon result: {result}") return json.dumps(result) except Exception as e: logger.error(f"Error in create_mortise_tenon: {str(e)}") return f"Error creating mortise and tenon joint: {str(e)}" @mcp.tool() def create_dovetail( ctx: Context, tail_id: str, pin_id: str, width: float = 1.0, height: float = 1.0, depth: float = 1.0, angle: float = 15.0, num_tails: int = 3, offset_x: float = 0.0, offset_y: float = 0.0, offset_z: float = 0.0 ) -> str: """Create a dovetail joint between two components""" try: logger.info(f"create_dovetail called with tail_id={tail_id}, pin_id={pin_id}, width={width}, height={height}, depth={depth}, angle={angle}, num_tails={num_tails}") sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "create_dovetail", "arguments": { "tail_id": tail_id, "pin_id": pin_id, "width": width, "height": height, "depth": depth, "angle": angle, "num_tails": num_tails, "offset_x": offset_x, "offset_y": offset_y, "offset_z": offset_z } }, request_id=ctx.request_id ) logger.info(f"create_dovetail result: {result}") return json.dumps(result) except Exception as e: logger.error(f"Error in create_dovetail: {str(e)}") return f"Error creating dovetail joint: {str(e)}" @mcp.tool() def create_finger_joint( ctx: Context, board1_id: str, board2_id: str, width: float = 1.0, height: float = 1.0, depth: float = 1.0, num_fingers: int = 5, offset_x: float = 0.0, offset_y: float = 0.0, offset_z: float = 0.0 ) -> str: """Create a finger joint (box joint) between two components""" try: logger.info(f"create_finger_joint called with board1_id={board1_id}, board2_id={board2_id}, width={width}, height={height}, depth={depth}, num_fingers={num_fingers}") sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "create_finger_joint", "arguments": { "board1_id": board1_id, "board2_id": board2_id, "width": width, "height": height, "depth": depth, "num_fingers": num_fingers, "offset_x": offset_x, "offset_y": offset_y, "offset_z": offset_z } }, request_id=ctx.request_id ) logger.info(f"create_finger_joint result: {result}") return json.dumps(result) except Exception as e: logger.error(f"Error in create_finger_joint: {str(e)}") return f"Error creating finger joint: {str(e)}" @mcp.tool() def eval_ruby( ctx: Context, code: str ) -> str: """Evaluate arbitrary Ruby code in Sketchup""" try: logger.info(f"eval_ruby called with code length: {len(code)}") sketchup = get_sketchup_connection() result = sketchup.send_command( method="tools/call", params={ "name": "eval_ruby", "arguments": { "code": code } }, request_id=ctx.request_id ) logger.info(f"eval_ruby result: {result}") # Format the response to include the result response = { "success": True, "result": result.get("content", [{"text": "Success"}])[0].get("text", "Success") if isinstance(result.get("content"), list) and len(result.get("content", [])) > 0 else "Success" } return json.dumps(response) except Exception as e: logger.error(f"Error in eval_ruby: {str(e)}") return json.dumps({ "success": False, "error": str(e) }) def main(): mcp.run() if __name__ == "__main__": main()

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mhyrr/sketchup-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server