# Directory Structure ``` ├── .gitignore ├── .python-version ├── bin │ └── cinema4d-mcp-wrapper ├── c4d_plugin │ └── mcp_server_plugin.pyp ├── LICENSE ├── main.py ├── pyproject.toml ├── README.md ├── setup.py ├── src │ └── cinema4d_mcp │ ├── __init__.py │ ├── config.py │ ├── server.py │ └── utils.py ├── tests │ ├── mcp_test_harness_gui.py │ ├── mcp_test_harness.jsonl │ └── test_server.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 3.12 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Python __pycache__/ *.py[cod] *$py.class *.so .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # Virtual environments venv/ env/ ENV/ # IDE files .idea/ .vscode/ *.swp *.swo # OS specific .DS_Store Thumbs.db ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Cinema4D MCP — Model Context Protocol (MCP) Server Cinema4D MCP Server connects Cinema 4D to Claude, enabling prompt-assisted 3D manipulation. ## Table of Contents - [Components](#components) - [Prerequisites](#prerequisites) - [Installation](#installation) - [Setup](#setup) - [Usage](#usage) - [Development](#development) - [Troubleshooting & Debugging](#troubleshooting--debugging) - [File Structure](#file-structure) - [Tool Commands](#tool-commands) ## Components 1. **C4D Plugin**: A socket server that listens for commands from the MCP server and executes them in the Cinema 4D environment. 2. **MCP Server**: A Python server that implements the MCP protocol and provides tools for Cinema 4D integration. ## Prerequisites - Cinema 4D (R2024+ recommended) - Python 3.10 or higher (for the MCP Server component) ## Installation To install the project, follow these steps: ### Clone the Repository ```bash git clone https://github.com/ttiimmaacc/cinema4d-mcp.git cd cinema4d-mcp ``` ### Install the MCP Server Package ```bash pip install -e . ``` ### Make the Wrapper Script Executable ```bash chmod +x bin/cinema4d-mcp-wrapper ``` ## Setup ### Cinema 4D Plugin Setup To set up the Cinema 4D plugin, follow these steps: 1. **Copy the Plugin File**: Copy the `c4d_plugin/mcp_server_plugin.pyp` file to Cinema 4D's plugin folder. The path varies depending on your operating system: - macOS: `/Users/USERNAME/Library/Preferences/Maxon/Maxon Cinema 4D/plugins/` - Windows: `C:\Users\USERNAME\AppData\Roaming\Maxon\Maxon Cinema 4D\plugins\` 2. **Start the Socket Server**: - Open Cinema 4D. - Go to Extensions > Socket Server Plugin - You should see a Socket Server Control dialog window. Click Start Server. ### Claude Desktop Configuration To configure Claude Desktop, you need to modify its configuration file: 1. **Open the Configuration File**: - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` - Windows: `%APPDATA%\Claude\claude_desktop_config.json` - Alternatively, use the Settings menu in Claude Desktop (Settings > Developer > Edit Config). 2. **Add MCP Server Configuration**: For development/unpublished server, add the following configuration: ```json "mcpServers": { "cinema4d": { "command": "python3", "args": ["/Users/username/cinema4d-mcp/main.py"] } } ``` 3. **Restart Claude Desktop** after updating the configuration file. <details> <summary>[TODO] For published server</summary> ```json { "mcpServers": { "cinema4d": { "command": "cinema4d-mcp-wrapper", "args": [] } } } ``` </details> ## Usage 1. Ensure the Cinema 4D Socket Server is running. 2. Open Claude Desktop and look for the hammer icon 🔨 in the input box, indicating MCP tools are available. 3. Use the available [Tool Commands](#tool-commands) to interact with Cinema 4D through Claude. ## Testing ### Command Line Testing To test the Cinema 4D socket server directly from the command line: ```bash python main.py ``` You should see output confirming the server's successful start and connection to Cinema 4D. ### Testing with MCP Test Harness The repository includes a simple test harness for running predefined command sequences: 1. **Test Command File** (`tests/mcp_test_harness.jsonl`): Contains a sequence of commands in JSONL format that can be executed in order. Each line represents a single MCP command with its parameters. 2. **GUI Test Runner** (`tests/mcp_test_harness_gui.py`): A simple Tkinter GUI for running the test commands: ```bash python tests/mcp_test_harness_gui.py ``` The GUI allows you to: - Select a JSONL test file - Run the commands in sequence - View the responses from Cinema 4D This test harness is particularly useful for: - Rapidly testing new commands - Verifying plugin functionality after updates - Recreating complex scenes for debugging - Testing compatibility across different Cinema 4D versions ## Troubleshooting & Debugging 1. Check the log files: ```bash tail -f ~/Library/Logs/Claude/mcp*.log ``` 2. Verify Cinema 4D shows connections in its console after you open Claude Desktop. 3. Test the wrapper script directly: ```bash cinema4d-mcp-wrapper ``` 4. If there are errors finding the mcp module, install it system-wide: ```bash pip install mcp ``` 5. For advanced debugging, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): ```bash npx @modelcontextprotocol/inspector uv --directory /Users/username/cinema4d-mcp run cinema4d-mcp ``` ## Project File Structure ``` cinema4d-mcp/ ├── .gitignore ├── LICENSE ├── README.md ├── main.py ├── pyproject.toml ├── setup.py ├── bin/ │ └── cinema4d-mcp-wrapper ├── c4d_plugin/ │ └── mcp_server_plugin.pyp ├── src/ │ └── cinema4d_mcp/ │ ├── __init__.py │ ├── server.py │ ├── config.py │ └── utils.py └── tests/ ├── test_server.py ├── mcp_test_harness.jsonl └── mcp_test_harness_gui.py ``` ## Tool Commands ### General Scene & Execution - `get_scene_info`: Get summary info about the active Cinema 4D scene. ✅ - `list_objects`: List all scene objects (with hierarchy). ✅ - `group_objects`: Group selected objects under a new null. ✅ - `execute_python`: Execute custom Python code inside Cinema 4D. ✅ - `save_scene`: Save the current Cinema 4D project to disk. ✅ - `load_scene`: Load a `.c4d` file into the scene. ✅ - `set_keyframe`: Set a keyframe on an objects property (position, rotation, etc.). ✅ ### Object Creation & Modification - `add_primitive`: Add a primitive (cube, sphere, cone, etc.) to the scene. ✅ - `modify_object`: Modify transform or attributes of an existing object. ✅ - `create_abstract_shape`: Create an organic, non-standard abstract form. ✅ ### Cameras & Animation - `create_camera`: Add a new camera to the scene. ✅ - `animate_camera`: Animate a camera along a path (linear or spline-based). ✅ ### Lighting & Materials - `create_light`: Add a light (omni, spot, etc.) to the scene. ✅ - `create_material`: Create a standard Cinema 4D material. ✅ - `apply_material`: Apply a material to a target object. ✅ - `apply_shader`: Generate and apply a stylized or procedural shader. ✅ ### Redshift Support - `validate_redshift_materials`: Check Redshift material setup and connections. ✅ ⚠️ (Redshift materials not fully implemented) ### MoGraph & Fields - `create_mograph_cloner`: Add a MoGraph Cloner (linear, radial, grid, etc.). ✅ - `add_effector`: Add a MoGraph Effector (Random, Plain, etc.). ✅ - `apply_mograph_fields`: Add and link a MoGraph Field to objects. ✅ ### Dynamics & Physics - `create_soft_body`: Add a Soft Body tag to an object. ✅ - `apply_dynamics`: Apply Rigid or Soft Body physics. ✅ ### Rendering & Preview - `render_frame`: Render a frame and save it to disk (file-based output only). ⚠️ (Works, but fails on large resolutions due to MemoryError: Bitmap Init failed. This is a resource limitation.) - `render_preview`: Render a quick preview and return base64 image (for AI). ✅ - `snapshot_scene`: Capture a snapshot of the scene (objects + preview image). ✅ ## Compatibility Plan & Roadmap | Cinema 4D Version | Python Version | Compatibility Status | Notes | | ----------------- | -------------- | -------------------- | ------------------------------------------------- | | R21 / S22 | Python 2.7 | ❌ Not supported | Legacy API and Python version too old | | R23 | Python 3.7 | 🔍 Not planned | Not currently tested | | S24 / R25 / S26 | Python 3.9 | ⚠️ Possible (TBD) | Requires testing and fallbacks for missing APIs | | 2023.0 / 2023.1 | Python 3.9 | 🧪 In progress | Targeting fallback support for core functionality | | 2023.2 | Python 3.10 | 🧪 In progress | Aligns with planned testing base | | 2024.0 | Python 3.11 | ✅ Supported | Verified | | 2025.0+ | Python 3.11 | ✅ Fully Supported | Primary development target | ### Compatibility Goals - **Short Term**: Ensure compatibility with C4D 2023.1+ (Python 3.9 and 3.10) - **Mid Term**: Add conditional handling for missing MoGraph and Field APIs - **Long Term**: Consider optional legacy plugin module for R23–S26 support if demand arises ## Recent Fixes - Context Awareness: Implemented robust object tracking using GUIDs. Commands creating objects return context (guid, actual_name, etc.). Subsequent commands correctly use GUIDs passed by the test harness/server to find objects reliably. - Object Finding: Reworked find_object_by_name to correctly handle GUIDs (numeric string format), fixed recursion errors, and improved reliability when doc.SearchObject fails. - GUID Detection: Command handlers (apply_material, create_mograph_cloner, add_effector, apply_mograph_fields, set_keyframe, group_objects) now correctly detect if identifiers passed in various parameters (object_name, target, target_name, list items) are GUIDs and search accordingly. - create_mograph_cloner: Fixed AttributeError for missing MoGraph parameters (like MG_LINEAR_PERSTEP) by using getattr fallbacks. Fixed logic bug where the found object wasn't correctly passed for cloning. - Rendering: Fixed TypeError in render_frame related to doc.ExecutePasses. snapshot_scene now correctly uses the working base64 render logic. Large render_frame still faces memory limits. - Registration: Fixed AttributeError for c4d.NilGuid. ``` -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python from setuptools import setup if __name__ == "__main__": setup() ``` -------------------------------------------------------------------------------- /src/cinema4d_mcp/config.py: -------------------------------------------------------------------------------- ```python """Configuration handling for Cinema 4D MCP Server.""" import os # Default configuration C4D_HOST = os.environ.get('C4D_HOST', '127.0.0.1') C4D_PORT = int(os.environ.get('C4D_PORT', 5555)) ``` -------------------------------------------------------------------------------- /src/cinema4d_mcp/__init__.py: -------------------------------------------------------------------------------- ```python """Cinema 4D MCP Server - Connect Claude to Cinema 4D""" __version__ = "0.1.0" from . import server def main(): """Main entry point for the package.""" server.mcp_app.run() def main_wrapper(): """Entry point for the wrapper script.""" main() ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [build-system] requires = ["setuptools>=42", "wheel"] build-backend = "setuptools.build_meta" [project] name = "cinema4d-mcp" version = "0.1.2" description = "Cinema 4D MCP Server for Claude Desktop with MoGraph support" authors = [ {name = "Your Name", email = "[email protected]"} ] readme = "README.md" requires-python = ">=3.9" dependencies = [ "mcp>=1.2.0", "starlette", ] [project.scripts] cinema4d-mcp-wrapper = "cinema4d_mcp:main_wrapper" cinema4d-mcp = "cinema4d_mcp:main" ``` -------------------------------------------------------------------------------- /src/cinema4d_mcp/utils.py: -------------------------------------------------------------------------------- ```python """Utility functions for Cinema 4D MCP Server.""" import socket import sys import logging # Configure logging logging.basicConfig( level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s', handlers=[ logging.StreamHandler(sys.stderr) ] ) logger = logging.getLogger("cinema4d-mcp") def check_c4d_connection(host, port): """Check if Cinema 4D socket server is running.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(5) result = sock.connect_ex((host, port)) sock.close() return result == 0 except Exception as e: logger.error(f"Error checking Cinema 4D connection: {e}") return False ``` -------------------------------------------------------------------------------- /tests/test_server.py: -------------------------------------------------------------------------------- ```python """Tests for the Cinema 4D MCP Server.""" import unittest import socket import json from unittest.mock import patch, MagicMock from cinema4d_mcp.server import send_to_c4d, C4DConnection class TestC4DServer(unittest.TestCase): """Test cases for Cinema 4D server functionality.""" def test_connection_disconnected(self): """Test behavior when connection is disconnected.""" connection = C4DConnection(sock=None, connected=False) result = send_to_c4d(connection, {"command": "test"}) self.assertIn("error", result) self.assertEqual(result["error"], "Not connected to Cinema 4D") @patch('socket.socket') def test_send_to_c4d(self, mock_socket): """Test sending commands to C4D with a mocked socket.""" # Setup mock mock_instance = MagicMock() mock_instance.recv.return_value = b'{"result": "success"}\n' mock_socket.return_value = mock_instance # Create connection with mock socket connection = C4DConnection(sock=mock_instance, connected=True) # Test sending a command result = send_to_c4d(connection, {"command": "test"}) # Verify command was sent correctly expected_send = b'{"command": "test"}\n' mock_instance.sendall.assert_called_once() self.assertEqual(result, {"result": "success"}) def test_send_to_c4d_exception(self): """Test error handling when sending fails.""" # Create a socket that raises an exception mock_socket = MagicMock() mock_socket.sendall.side_effect = Exception("Test error") connection = C4DConnection(sock=mock_socket, connected=True) result = send_to_c4d(connection, {"command": "test"}) self.assertIn("error", result) self.assertIn("Test error", result["error"]) if __name__ == '__main__': unittest.main() ``` -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- ```python #!/usr/bin/env python3 """ Cinema 4D MCP Server - Main entry point script This script starts the Cinema 4D MCP server either directly or through package imports, allowing it to be run both as a script and as a module. """ import sys import os import socket import logging import traceback # Configure logging to stderr logging.basicConfig( level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[logging.StreamHandler(sys.stderr)], ) logger = logging.getLogger("cinema4d-mcp") # Add the src directory to the Python path src_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src") if os.path.exists(src_path): sys.path.insert(0, src_path) # Add the project root to Python path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) def log_to_stderr(message): """Log a message to stderr for Claude Desktop to capture.""" print(message, file=sys.stderr, flush=True) def main(): """Main entry point function.""" log_to_stderr("========== CINEMA 4D MCP SERVER STARTING ==========") log_to_stderr(f"Python version: {sys.version}") log_to_stderr(f"Current directory: {os.getcwd()}") log_to_stderr(f"Python path: {sys.path}") # Check if Cinema 4D socket is available c4d_host = os.environ.get("C4D_HOST", "127.0.0.1") c4d_port = int(os.environ.get("C4D_PORT", 5555)) log_to_stderr(f"Checking connection to Cinema 4D on {c4d_host}:{c4d_port}") try: test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) test_socket.settimeout(5) # Set 5 second timeout test_socket.connect((c4d_host, c4d_port)) test_socket.close() log_to_stderr("✅ Successfully connected to Cinema 4D socket!") except Exception as e: log_to_stderr(f"❌ Could not connect to Cinema 4D socket: {e}") log_to_stderr( " The server will still start, but Cinema 4D integration won't work!" ) try: log_to_stderr("Importing cinema4d_mcp...") from cinema4d_mcp import main as package_main log_to_stderr("🚀 Starting Cinema 4D MCP Server...") package_main() except Exception as e: log_to_stderr(f"❌ Error starting server: {e}") log_to_stderr(traceback.format_exc()) sys.exit(1) if __name__ == "__main__": main() ``` -------------------------------------------------------------------------------- /tests/mcp_test_harness_gui.py: -------------------------------------------------------------------------------- ```python import socket import json import time import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext import threading import os import copy # Needed for deep copying commands SERVER_HOST = "localhost" SERVER_PORT = 5555 class MCPTestHarnessGUI: def __init__(self, root): self.root = root self.root.title("MCP Test Harness") # --- GUI Elements --- self.file_label = tk.Label(root, text="Test File:") self.file_label.grid(row=0, column=0, sticky="w") self.file_entry = tk.Entry(root, width=50) self.file_entry.grid(row=0, column=1) self.browse_button = tk.Button(root, text="Browse", command=self.browse_file) self.browse_button.grid(row=0, column=2) self.run_button = tk.Button(root, text="Run Test", command=self.run_test_thread) self.run_button.grid(row=1, column=1, pady=10) self.log_text = scrolledtext.ScrolledText( root, wrap=tk.WORD, width=80, height=30 ) self.log_text.grid(row=2, column=0, columnspan=3) # --- ADDED: Storage for GUIDs --- self.guid_map = {} # Maps requested_name -> actual_guid def browse_file(self): filename = filedialog.askopenfilename( filetypes=[("JSON Lines", "*.jsonl"), ("All Files", "*.*")] ) if filename: self.file_entry.delete(0, tk.END) self.file_entry.insert(0, filename) def log(self, message): # --- Modified to handle updates from thread --- def update_log(): self.log_text.insert(tk.END, message + "\n") self.log_text.see(tk.END) # Schedule the update in the main Tkinter thread self.root.after(0, update_log) print(message) # Also print to console def run_test_thread(self): # Disable run button during test self.run_button.config(state=tk.DISABLED) # Clear log and GUID map for new run self.log_text.delete(1.0, tk.END) self.guid_map = {} # Start the test in a separate thread threading.Thread(target=self.run_test, daemon=True).start() # --- ADDED: Recursive substitution function --- def substitute_placeholders(self, data_structure): """Recursively substitutes known names with GUIDs in dicts and lists.""" if isinstance(data_structure, dict): new_dict = {} for key, value in data_structure.items(): # Substitute the value if it's a string matching a known name if isinstance(value, str) and value in self.guid_map: new_dict[key] = self.guid_map[value] self.log( f" Substituted '{key}': '{value}' -> '{self.guid_map[value]}'" ) # Recursively process nested structures elif isinstance(value, (dict, list)): new_dict[key] = self.substitute_placeholders(value) else: new_dict[key] = value return new_dict elif isinstance(data_structure, list): new_list = [] for item in data_structure: # Substitute the item if it's a string matching a known name if isinstance(item, str) and item in self.guid_map: new_list.append(self.guid_map[item]) self.log( f" Substituted item in list: '{item}' -> '{self.guid_map[item]}'" ) # Recursively process nested structures elif isinstance(item, (dict, list)): new_list.append(self.substitute_placeholders(item)) else: new_list.append(item) return new_list else: # If it's not a dict or list, check if the value itself needs substitution if isinstance(data_structure, str) and data_structure in self.guid_map: substituted_value = self.guid_map[data_structure] self.log( f" Substituted value: '{data_structure}' -> '{substituted_value}'" ) return substituted_value return data_structure # Return other types unchanged def run_test(self): test_file = self.file_entry.get() if not os.path.exists(test_file): messagebox.showerror("Error", "Test file does not exist.") self.run_button.config(state=tk.NORMAL) # Re-enable button return try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: self.log(f"Connecting to MCP server at {SERVER_HOST}:{SERVER_PORT}...") sock.connect((SERVER_HOST, SERVER_PORT)) self.log("Connected ✅\n--- Test Start ---") with open(test_file, "r", encoding="utf-8") as f: for line_num, line in enumerate(f, 1): if not line.strip(): continue # Skip empty lines try: # Load original command from file original_command = json.loads(line.strip()) self.log( f"\n▶️ Command {line_num} (Original): {json.dumps(original_command)}" ) # --- MODIFIED: Substitute placeholders before sending --- # Deep copy to avoid modifying the original dict before logging command_to_send = copy.deepcopy(original_command) command_to_send = self.substitute_placeholders( command_to_send ) if command_to_send != original_command: self.log( f" Command {line_num} (Substituted): {json.dumps(command_to_send)}" ) # Send the potentially modified command sock.sendall( (json.dumps(command_to_send) + "\n").encode("utf-8") ) # Receive response (increase buffer size significantly for base64 previews) response_data = b"" sock.settimeout(120.0) # Set generous timeout for receiving while True: try: chunk = sock.recv(32768) # Read larger chunks if not chunk: # Connection closed prematurely? if not response_data: raise ConnectionAbortedError( "Server closed connection unexpectedly before sending response." ) break # No more data response_data += chunk # Basic check for newline delimiter, might need refinement for large data if b"\n" in chunk: break except socket.timeout: # Check if we received *any* data before timeout if not response_data: raise TimeoutError( f"Timeout waiting for response to Command {line_num}" ) else: self.log( f" Warning: Socket timeout, but received partial data ({len(response_data)} bytes). Assuming complete." ) break # Process what we got sock.settimeout(None) # Reset timeout # Decode and parse response response_text = response_data.decode("utf-8").strip() if not response_text: raise ValueError("Received empty response from server.") decoded_response = json.loads(response_text) # Log the response (truncate potentially huge base64 data) loggable_response = copy.deepcopy(decoded_response) if isinstance(loggable_response, dict): # Check common keys for base64 data and truncate for key in ["image_base64", "image_data"]: if ( key in loggable_response and isinstance(loggable_response[key], str) and len(loggable_response[key]) > 100 ): loggable_response[key] = ( loggable_response[key][:50] + "... [truncated]" ) # Also check within nested 'render' dict for snapshot if "render" in loggable_response and isinstance( loggable_response["render"], dict ): for key in ["image_base64", "image_data"]: render_dict = loggable_response["render"] if ( key in render_dict and isinstance(render_dict[key], str) and len(render_dict[key]) > 100 ): render_dict[key] = ( render_dict[key][:50] + "... [truncated]" ) self.log( f"✅ Response {line_num}: {json.dumps(loggable_response, indent=2)}" ) # --- ADDED: Capture GUID from response --- if isinstance(decoded_response, dict): # Check common patterns for created objects context_keys = [ "object", "light", "camera", "material", "cloner", "effector", "field", "shape", "group", ] for key in context_keys: if key in decoded_response and isinstance( decoded_response[key], dict ): obj_info = decoded_response[key] req_name = obj_info.get("requested_name") guid = obj_info.get("guid") act_name = obj_info.get("actual_name") if req_name and guid: self.guid_map[req_name] = guid self.log( f" Captured GUID: '{req_name}' -> {guid} (Actual name: '{act_name}')" ) # Also map actual name if different, preferring requested name if collision if ( act_name and act_name != req_name and act_name not in self.guid_map ): self.guid_map[act_name] = guid self.log( f" Mapped actual name: '{act_name}' -> {guid}" ) break # Assume only one primary object context per response # Brief pause between commands time.sleep(0.1) except json.JSONDecodeError as e: self.log(f"❌ Error decoding JSON for line {line_num}: {e}") self.log(f" Raw line: {line.strip()}") break # Stop test on error except Exception as cmd_e: self.log(f"❌ Error processing command {line_num}: {cmd_e}") import traceback self.log(traceback.format_exc()) break # Stop test on error self.log("--- Test End ---") except ConnectionRefusedError: self.log( f"❌ Connection Refused: Ensure C4D plugin server is running on {SERVER_HOST}:{SERVER_PORT}." ) messagebox.showerror( "Connection Error", "Connection Refused. Is the Cinema 4D plugin server running?", ) except socket.timeout: self.log( f"❌ Connection Timeout: Could not connect to {SERVER_HOST}:{SERVER_PORT}." ) messagebox.showerror("Connection Error", "Connection Timeout.") except Exception as e: self.log(f"❌ Unexpected Error: {str(e)}") import traceback self.log(traceback.format_exc()) messagebox.showerror("Error", f"An unexpected error occurred:\n{str(e)}") finally: # Re-enable run button after test finishes or errors out self.root.after(0, lambda: self.run_button.config(state=tk.NORMAL)) if __name__ == "__main__": root = tk.Tk() app = MCPTestHarnessGUI(root) root.mainloop() ``` -------------------------------------------------------------------------------- /src/cinema4d_mcp/server.py: -------------------------------------------------------------------------------- ```python """Cinema 4D MCP Server.""" import socket import json import os import math import time from dataclasses import dataclass from typing import Any, Dict, List, Optional, Union from contextlib import asynccontextmanager from mcp.server.fastmcp import FastMCP, Context from starlette.routing import Route from starlette.responses import JSONResponse from .config import C4D_HOST, C4D_PORT from .utils import logger, check_c4d_connection @dataclass class C4DConnection: sock: Optional[socket.socket] = None connected: bool = False # Asynchronous context manager for Cinema 4D connection @asynccontextmanager async def c4d_connection_context(): """Asynchronous context manager for Cinema 4D connection.""" connection = C4DConnection() try: # Initialize connection to Cinema 4D sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((C4D_HOST, C4D_PORT)) connection.sock = sock connection.connected = True logger.info(f"✅ Connected to Cinema 4D at {C4D_HOST}:{C4D_PORT}") yield connection # Yield the connection except Exception as e: logger.error(f"❌ Failed to connect to Cinema 4D: {str(e)}") connection.connected = False # Ensure connection is marked as not connected yield connection # Still yield the connection object finally: # Clean up on server shutdown if connection.sock: connection.sock.close() logger.info("🔌 Disconnected from Cinema 4D") def send_to_c4d(connection: C4DConnection, command: Dict[str, Any]) -> Dict[str, Any]: """Send a command to Cinema 4D and get the response with improved timeout handling.""" if not connection.connected or not connection.sock: return {"error": "Not connected to Cinema 4D"} # Set appropriate timeout based on command type command_type = command.get("command", "") # Long-running operations need longer timeouts if command_type in ["render_frame", "apply_mograph_fields"]: timeout = 120 # 2 minutes for render operations logger.info(f"Using extended timeout ({timeout}s) for {command_type}") else: timeout = 20 # Default timeout for regular operations try: # Convert command to JSON and send it command_json = json.dumps(command) + "\n" # Add newline as message delimiter logger.debug(f"Sending command: {command_type}") connection.sock.sendall(command_json.encode("utf-8")) # Set socket timeout connection.sock.settimeout(timeout) # Receive response response_data = b"" start_time = time.time() max_time = start_time + timeout # Log for long-running operations if command_type in ["render_frame", "apply_mograph_fields"]: logger.info( f"Waiting for response from {command_type} (timeout: {timeout}s)" ) while time.time() < max_time: try: chunk = connection.sock.recv(4096) if not chunk: # If we receive an empty chunk, the connection might be closed if not response_data: logger.error( f"Connection closed by Cinema 4D during {command_type}" ) return { "error": f"Connection closed by Cinema 4D during {command_type}" } break response_data += chunk # For long operations, log progress on data receipt elapsed = time.time() - start_time if ( command_type in ["render_frame", "apply_mograph_fields"] and elapsed > 5 ): logger.debug( f"Received partial data for {command_type} ({len(response_data)} bytes, {elapsed:.1f}s elapsed)" ) if b"\n" in chunk: # Message complete when we see a newline logger.debug(f"Received complete response for {command_type}") break except socket.timeout: logger.error(f"Socket timeout while receiving data for {command_type}") return { "error": f"Timeout waiting for response from Cinema 4D ({timeout}s) for {command_type}" } # Parse and return response if not response_data: logger.error(f"No response received from Cinema 4D for {command_type}") return {"error": f"No response received from Cinema 4D for {command_type}"} response_text = response_data.decode("utf-8").strip() try: return json.loads(response_text) except json.JSONDecodeError as e: # If JSON parsing fails, log the exact response for debugging logger.error(f"Failed to parse JSON response: {str(e)}") logger.error(f"Raw response (first 200 chars): {response_text[:200]}...") return {"error": f"Invalid response from Cinema 4D: {str(e)}"} except socket.timeout: logger.error(f"Socket timeout during {command_type} ({timeout}s)") return { "error": f"Timeout communicating with Cinema 4D ({timeout}s) for {command_type}" } except Exception as e: logger.error(f"Communication error during {command_type}: {str(e)}") return {"error": f"Communication error: {str(e)}"} async def homepage(request): """Handle homepage requests to check if server is running.""" c4d_available = check_c4d_connection(C4D_HOST, C4D_PORT) return JSONResponse( { "status": "ok", "cinema4d_connected": c4d_available, "host": C4D_HOST, "port": C4D_PORT, } ) # Initialize our FastMCP server mcp = FastMCP(title="Cinema4D", routes=[Route("/", endpoint=homepage)]) @mcp.tool() async def get_scene_info(ctx: Context) -> str: """Get information about the current Cinema 4D scene.""" async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" response = send_to_c4d(connection, {"command": "get_scene_info"}) if "error" in response: return f"❌ Error: {response['error']}" # Format scene info nicely scene_info = response.get("scene_info", {}) return f""" # Cinema 4D Scene Information - **Filename**: {scene_info.get('filename', 'Untitled')} - **Objects**: {scene_info.get('object_count', 0)} - **Polygons**: {scene_info.get('polygon_count', 0):,} - **Materials**: {scene_info.get('material_count', 0)} - **Current Frame**: {scene_info.get('current_frame', 0)} - **FPS**: {scene_info.get('fps', 30)} - **Frame Range**: {scene_info.get('frame_start', 0)} - {scene_info.get('frame_end', 90)} """ @mcp.tool() async def add_primitive( primitive_type: str, name: Optional[str] = None, position: Optional[List[float]] = None, size: Optional[List[float]] = None, ctx: Context = None, ) -> str: """ Add a primitive object to the Cinema 4D scene. Args: primitive_type: Type of primitive (cube, sphere, cone, cylinder, plane, etc.) name: Optional name for the new object position: Optional [x, y, z] position size: Optional [x, y, z] size or dimensions """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = { "command": "add_primitive", "type": primitive_type, } if name: command["object_name"] = name if position: command["position"] = position if size: command["size"] = size # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" object_info = response.get("object", {}) return response @mcp.tool() async def modify_object( object_name: str, properties: Dict[str, Any], ctx: Context ) -> str: """ Modify properties of an existing object. Args: object_name: Name of the object to modify properties: Dictionary of properties to modify (position, rotation, scale, etc.) """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Send command to Cinema 4D response = send_to_c4d( connection, { "command": "modify_object", "object_name": object_name, "properties": properties, }, ) if "error" in response: return f"❌ Error: {response['error']}" # Generate summary of what was modified modified_props = [] for prop, value in properties.items(): modified_props.append(f"- **{prop}**: {value}") return response @mcp.tool() async def list_objects(ctx: Context) -> str: """List all objects in the current Cinema 4D scene.""" async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" response = send_to_c4d(connection, {"command": "list_objects"}) if "error" in response: return f"❌ Error: {response['error']}" objects = response.get("objects", []) if not objects: return "No objects found in the scene." # Format objects as a hierarchical list with indentation object_list = [] for obj in objects: # Calculate indentation based on object's depth in hierarchy indent = " " * obj.get("depth", 0) object_list.append(f"{indent}- **{obj['name']}** ({obj['type']})") return response @mcp.tool() async def create_material( name: str, color: Optional[List[float]] = None, properties: Optional[Dict[str, Any]] = None, ctx: Context = None, ) -> str: """ Create a new material in Cinema 4D. Args: name: Name for the new material color: Optional [R, G, B] color (values 0-1) properties: Optional additional material properties """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = {"command": "create_material", "material_name": name} if color: command["color"] = color if properties: command["properties"] = properties # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" material_info = response.get("material", {}) return response @mcp.tool() async def apply_material(material_name: str, object_name: str, ctx: Context) -> str: """ Apply a material to an object. Args: material_name: Name of the material to apply object_name: Name of the object to apply the material to """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Send command to Cinema 4D response = send_to_c4d( connection, { "command": "apply_material", "material_name": material_name, "object_name": object_name, }, ) if "error" in response: return f"❌ Error: {response['error']}" return response @mcp.tool() async def render_frame( output_path: Optional[str] = None, width: Optional[int] = None, height: Optional[int] = None, ctx: Context = None, ) -> str: """ Render the current frame. Args: output_path: Optional path to save the rendered image width: Optional render width in pixels height: Optional render height in pixels """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = {"command": "render_frame"} if output_path: command["output_path"] = output_path if width: command["width"] = width if height: command["height"] = height # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" render_info = response.get("render_info", {}) return response @mcp.tool() async def set_keyframe( object_name: str, property_name: str, value: Any, frame: int, ctx: Context ) -> str: """ Set a keyframe for an object property. Args: object_name: Name of the object property_name: Name of the property to keyframe (e.g., 'position.x') value: Value to set at the keyframe frame: Frame number to set the keyframe at """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Send command to Cinema 4D response = send_to_c4d( connection, { "command": "set_keyframe", "object_name": object_name, "property_name": property_name, "value": value, "frame": frame, }, ) if "error" in response: return f"❌ Error: {response['error']}" return response @mcp.tool() async def save_scene(file_path: Optional[str] = None, ctx: Context = None) -> str: """ Save the current Cinema 4D scene. Args: file_path: Optional path to save the scene to """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = {"command": "save_scene"} if file_path: command["file_path"] = file_path # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" return response @mcp.tool() async def load_scene(file_path: str, ctx: Context) -> str: """ Load a Cinema 4D scene file. Args: file_path: Path to the scene file to load """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Send command to Cinema 4D response = send_to_c4d( connection, {"command": "load_scene", "file_path": file_path} ) if "error" in response: return f"❌ Error: {response['error']}" return response @mcp.tool() async def create_mograph_cloner( cloner_type: str, name: Optional[str] = None, ctx: Context = None ) -> str: """ Create a MoGraph Cloner object of specified type. Args: cloner_type: Type of cloner (grid, radial, linear) name: Optional name for the cloner """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" command = {"command": "create_mograph_cloner", "mode": cloner_type} if name: command["cloner_name"] = name response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" object_info = response.get("object", {}) return response @mcp.tool() async def add_effector( effector_type: str, name: Optional[str] = None, target: Optional[str] = None, ctx: Context = None, ) -> str: """ Add a MoGraph Effector to the scene. Args: effector_type: Type of effector (random, shader, field) name: Optional name for the effector target: Optional target object (e.g., cloner) to apply the effector to """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" command = {"command": "add_effector", "effector_type": effector_type} if name: command["effector_name"] = name if target: command["cloner_name"] = target response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" object_info = response.get("object", {}) return response @mcp.tool() async def apply_mograph_fields( field_type: str, target: Optional[str] = None, field_name: Optional[str] = None, parameters: Optional[Dict[str, Any]] = None, ctx: Context = None, ) -> str: """ Create and apply a MoGraph Field. Args: field_type: Type of field (spherical, box, cylindrical, linear, radial, noise) target: Optional target object to apply the field to field_name: Optional name for the field parameters: Optional parameters for the field (strength, falloff) """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Build the command with required parameters command = {"command": "apply_mograph_fields", "field_type": field_type} # Add optional parameters if target: command["target_name"] = target if field_name: command["field_name"] = field_name if parameters: command["parameters"] = parameters # Log the command for debugging logger.info(f"Sending apply_mograph_fields command: {command}") # Send the command to Cinema 4D response = send_to_c4d(connection, command) # Handle error responses if "error" in response: error_msg = response["error"] logger.error(f"Error applying field: {error_msg}") return f"❌ Error: {error_msg}" # Extract field info from response field_info = response.get("field", {}) # Build a response message field_name = field_info.get("name", f"{field_type.capitalize()} Field") applied_to = field_info.get("applied_to", "None") # Additional parameters if available params_info = "" if "strength" in field_info: params_info += f"\n- **Strength**: {field_info.get('strength')}" if "falloff" in field_info: params_info += f"\n- **Falloff**: {field_info.get('falloff')}" return response @mcp.tool() async def create_soft_body(object_name: str, ctx: Context = None) -> str: """ Add soft body dynamics to the specified object. Args: object_name: Name of the object to convert to a soft body """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" response = send_to_c4d( connection, {"command": "create_soft_body", "object_name": object_name} ) if "error" in response: return f"❌ Error: {response['error']}" return response @mcp.tool() async def apply_dynamics( object_name: str, dynamics_type: str, ctx: Context = None ) -> str: """ Add dynamics (rigid or soft) to the specified object. Args: object_name: Name of the object to apply dynamics to dynamics_type: Type of dynamics to apply (rigid, soft) """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" response = send_to_c4d( connection, { "command": "apply_dynamics", "object_name": object_name, "type": dynamics_type, }, ) if "error" in response: return f"❌ Error: {response['error']}" return response @mcp.tool() async def create_abstract_shape( shape_type: str, name: Optional[str] = None, ctx: Context = None ) -> str: """ Create an organic, abstract shape. Args: shape_type: Type of shape (blob, metaball) name: Optional name for the shape """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" command = {"command": "create_abstract_shape", "shape_type": shape_type} if name: command["object_name"] = name response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" object_info = response.get("object", {}) return response @mcp.tool() async def create_camera( name: Optional[str] = None, position: Optional[List[float]] = None, properties: Optional[Dict[str, Any]] = None, ctx: Context = None, ) -> Dict[str, Any]: """ Create a new camera in the scene. Args: name: Optional name for the new camera. position: Optional [x, y, z] position. properties: Optional dictionary of camera properties (e.g., {"focal_length": 50}). """ # Generate a default name if none provided - use the name from the plugin side if needed requested_name = name async with c4d_connection_context() as connection: if not connection.connected: # Return error as dictionary for consistency return {"error": "❌ Not connected to Cinema 4D"} command = {"command": "create_camera"} if requested_name: command["name"] = ( requested_name # Use the 'name' key expected by the handler ) if position: command["position"] = position if properties: command["properties"] = properties response = send_to_c4d(connection, command) return response @mcp.tool() async def create_light( light_type: str, name: Optional[str] = None, ctx: Context = None ) -> str: """ Add a light to the scene. Args: light_type: Type of light (area, dome, spot) name: Optional name for the light """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" command = {"command": "create_light", "type": light_type} if name: command["object_name"] = name response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" object_info = response.get("object", {}) return response @mcp.tool() async def apply_shader( shader_type: str, material_name: Optional[str] = None, object_name: Optional[str] = None, ctx: Context = None, ) -> str: """ Create and apply a specialized shader material. Args: shader_type: Type of shader (noise, gradient, fresnel, etc) material_name: Optional name of material to apply shader to object_name: Optional name of object to apply the material to """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" command = {"command": "apply_shader", "shader_type": shader_type} if material_name: command["material_name"] = material_name if object_name: command["object_name"] = object_name response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" shader_info = response.get("shader", {}) material_name = shader_info.get("material", "New Material") applied_to = shader_info.get("applied_to", "None") applied_msg = f" and applied to '{applied_to}'" if applied_to != "None" else "" return response @mcp.tool() async def animate_camera( animation_type: str, camera_name: Optional[str] = None, positions: Optional[List[List[float]]] = None, frames: Optional[List[int]] = None, ctx: Context = None, ) -> str: """ Create a camera animation. Args: animation_type: Type of animation (wiggle, orbit, spline, linear) camera_name: Optional name of camera to animate positions: Optional list of [x,y,z] camera positions for keyframes frames: Optional list of frame numbers for keyframes """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Create command with the animation type command = {"command": "animate_camera", "path_type": animation_type} # Add camera name if provided if camera_name: command["camera_name"] = camera_name # Handle positions and frames if provided if positions: command["positions"] = positions # Generate frames if not provided (starting at 0 with 15 frame intervals) if not frames: frames = [i * 15 for i in range(len(positions))] command["frames"] = frames if animation_type == "orbit": # For orbit animations, we need to generate positions in a circle # if none are provided if not positions: # Create a set of default positions for an orbit animation radius = 200 # Default orbit radius height = 100 # Default height points = 12 # Number of points around the circle orbit_positions = [] orbit_frames = [] # Create positions in a circle for i in range(points): angle = (i / points) * 2 * 3.14159 # Convert to radians x = radius * math.cos(angle) z = radius * math.sin(angle) y = height orbit_positions.append([x, y, z]) orbit_frames.append(i * 10) # 10 frames between positions command["positions"] = orbit_positions command["frames"] = orbit_frames # Send the command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" # Get the camera animation info camera_info = response.get("camera_animation", {}) # Build a response message frames_info = "" if "frame_range" in camera_info: frames_info = ( f"\n- **Frame Range**: {camera_info.get('frame_range', [0, 0])}" ) keyframe_info = "" if "keyframe_count" in camera_info: keyframe_info = f"\n- **Keyframes**: {camera_info.get('keyframe_count', 0)}" return response @mcp.tool() async def execute_python_script(script: str, ctx: Context) -> str: """ Execute a Python script in Cinema 4D. Args: script: Python code to execute in Cinema 4D """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Send command to Cinema 4D response = send_to_c4d( connection, {"command": "execute_python", "script": script} ) if "error" in response: return f"❌ Error: {response['error']}" result = response.get("result", "No output") return response @mcp.tool() async def group_objects( object_names: List[str], group_name: Optional[str] = None, ctx: Context = None ) -> str: """ Group multiple objects under a null object. Args: object_names: List of object names to group group_name: Optional name for the group """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = {"command": "group_objects", "object_names": object_names} if group_name: command["group_name"] = group_name # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" group_info = response.get("group", {}) # Format object list for display objects_str = ", ".join(object_names) if len(objects_str) > 50: # Truncate if too long objects_str = objects_str[:47] + "..." return response @mcp.tool() async def render_preview( width: Optional[int] = None, height: Optional[int] = None, frame: Optional[int] = None, ctx: Context = None, ) -> str: """ Render the current view and return a base64-encoded preview image. Args: width: Optional preview width in pixels height: Optional preview height in pixels frame: Optional frame number to render """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = {"command": "render_preview"} if width: command["width"] = width if height: command["height"] = height if frame is not None: command["frame"] = frame # Set longer timeout for rendering logger.info(f"Sending render_preview command with parameters: {command}") # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" # Check if the response contains the base64 image data if "image_data" not in response: return "❌ Error: No image data returned from Cinema 4D" # Get image dimensions preview_width = response.get("width", width or "default") preview_height = response.get("height", height or "default") # Display the image using markdown image_data = response["image_data"] image_format = response.get("format", "png") # Note: The plugin handler handle_render_preview was already designed # to return the structure needed for image display if successful. return response # Return the raw dictionary @mcp.tool() async def snapshot_scene( file_path: Optional[str] = None, include_assets: bool = False, ctx: Context = None ) -> str: """ Create a snapshot of the current scene state. Args: file_path: Optional path to save the snapshot include_assets: Whether to include external assets in the snapshot """ async with c4d_connection_context() as connection: if not connection.connected: return "❌ Not connected to Cinema 4D" # Prepare command command = {"command": "snapshot_scene"} if file_path: command["file_path"] = file_path command["include_assets"] = include_assets # Send command to Cinema 4D response = send_to_c4d(connection, command) if "error" in response: return f"❌ Error: {response['error']}" snapshot_info = response.get("snapshot", {}) # Extract information path = snapshot_info.get("path", file_path or "Default location") size = snapshot_info.get("size", "Unknown") timestamp = snapshot_info.get("timestamp", "Unknown") # Format assets information if available assets_info = "" if "assets" in snapshot_info: assets_count = len(snapshot_info["assets"]) assets_info = f"\n- **Assets Included**: {assets_count}" return response @mcp.resource("c4d://primitives") def get_primitives_info() -> str: """Get information about available Cinema 4D primitives.""" return """ # Cinema 4D Primitive Objects ## Cube - **Parameters**: size, segments ## Sphere - **Parameters**: radius, segments ## Cylinder - **Parameters**: radius, height, segments ## Cone - **Parameters**: radius, height, segments ## Plane - **Parameters**: width, height, segments ## Torus - **Parameters**: outer radius, inner radius, segments ## Pyramid - **Parameters**: width, height, depth ## Platonic - **Parameters**: radius, type (tetrahedron, hexahedron, octahedron, dodecahedron, icosahedron) """ @mcp.resource("c4d://material_types") def get_material_types() -> str: """Get information about available Cinema 4D material types and their properties.""" return """ # Cinema 4D Material Types ## Standard Material - **Color**: Base diffuse color - **Specular**: Highlight color and intensity - **Reflection**: Surface reflectivity - **Transparency**: Surface transparency - **Bump**: Surface bumpiness or displacement ## Physical Material - **Base Color**: Main surface color - **Specular**: Surface glossiness and reflectivity - **Roughness**: Surface irregularity - **Metallic**: Metal-like properties - **Transparency**: Light transmission properties - **Emission**: Self-illumination properties - **Normal**: Surface detail without geometry - **Displacement**: Surface geometry modification """ @mcp.resource("c4d://status") def get_connection_status() -> str: """Get the current connection status to Cinema 4D.""" is_connected = check_c4d_connection(C4D_HOST, C4D_PORT) status = ( "✅ Connected to Cinema 4D" if is_connected else "❌ Not connected to Cinema 4D" ) return f""" # Cinema 4D Connection Status {status} ## Connection Details - **Host**: {C4D_HOST} - **Port**: {C4D_PORT} """ mcp_app = mcp ```