#
tokens: 20343/50000 12/12 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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:
--------------------------------------------------------------------------------

```
1 | 3.12
2 | 
```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
 1 | # Python
 2 | __pycache__/
 3 | *.py[cod]
 4 | *$py.class
 5 | *.so
 6 | .Python
 7 | build/
 8 | develop-eggs/
 9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
22 | 
23 | # Virtual environments
24 | venv/
25 | env/
26 | ENV/
27 | 
28 | # IDE files
29 | .idea/
30 | .vscode/
31 | *.swp
32 | *.swo
33 | 
34 | # OS specific
35 | .DS_Store
36 | Thumbs.db
37 | 
38 | 
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Cinema4D MCP — Model Context Protocol (MCP) Server
  2 | 
  3 | Cinema4D MCP Server connects Cinema 4D to Claude, enabling prompt-assisted 3D manipulation.
  4 | 
  5 | ## Table of Contents
  6 | 
  7 | - [Components](#components)
  8 | - [Prerequisites](#prerequisites)
  9 | - [Installation](#installation)
 10 | - [Setup](#setup)
 11 | - [Usage](#usage)
 12 | - [Development](#development)
 13 | - [Troubleshooting & Debugging](#troubleshooting--debugging)
 14 | - [File Structure](#file-structure)
 15 | - [Tool Commands](#tool-commands)
 16 | 
 17 | ## Components
 18 | 
 19 | 1. **C4D Plugin**: A socket server that listens for commands from the MCP server and executes them in the Cinema 4D environment.
 20 | 2. **MCP Server**: A Python server that implements the MCP protocol and provides tools for Cinema 4D integration.
 21 | 
 22 | ## Prerequisites
 23 | 
 24 | - Cinema 4D (R2024+ recommended)
 25 | - Python 3.10 or higher (for the MCP Server component)
 26 | 
 27 | ## Installation
 28 | 
 29 | To install the project, follow these steps:
 30 | 
 31 | ### Clone the Repository
 32 | 
 33 | ```bash
 34 | git clone https://github.com/ttiimmaacc/cinema4d-mcp.git
 35 | cd cinema4d-mcp
 36 | ```
 37 | 
 38 | ### Install the MCP Server Package
 39 | 
 40 | ```bash
 41 | pip install -e .
 42 | ```
 43 | 
 44 | ### Make the Wrapper Script Executable
 45 | 
 46 | ```bash
 47 | chmod +x bin/cinema4d-mcp-wrapper
 48 | ```
 49 | 
 50 | ## Setup
 51 | 
 52 | ### Cinema 4D Plugin Setup
 53 | 
 54 | To set up the Cinema 4D plugin, follow these steps:
 55 | 
 56 | 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:
 57 | 
 58 |    - macOS: `/Users/USERNAME/Library/Preferences/Maxon/Maxon Cinema 4D/plugins/`
 59 |    - Windows: `C:\Users\USERNAME\AppData\Roaming\Maxon\Maxon Cinema 4D\plugins\`
 60 | 
 61 | 2. **Start the Socket Server**:
 62 |    - Open Cinema 4D.
 63 |    - Go to Extensions > Socket Server Plugin
 64 |    - You should see a Socket Server Control dialog window. Click Start Server.
 65 | 
 66 | ### Claude Desktop Configuration
 67 | 
 68 | To configure Claude Desktop, you need to modify its configuration file:
 69 | 
 70 | 1. **Open the Configuration File**:
 71 | 
 72 |    - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
 73 |    - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
 74 |    - Alternatively, use the Settings menu in Claude Desktop (Settings > Developer > Edit Config).
 75 | 
 76 | 2. **Add MCP Server Configuration**:
 77 |    For development/unpublished server, add the following configuration:
 78 |    ```json
 79 |    "mcpServers": {
 80 |      "cinema4d": {
 81 |        "command": "python3",
 82 |        "args": ["/Users/username/cinema4d-mcp/main.py"]
 83 |      }
 84 |    }
 85 |    ```
 86 | 3. **Restart Claude Desktop** after updating the configuration file.
 87 | <details>
 88 | 
 89 |   <summary>[TODO] For published server</summary>
 90 | 
 91 | ```json
 92 | {
 93 |   "mcpServers": {
 94 |     "cinema4d": {
 95 |       "command": "cinema4d-mcp-wrapper",
 96 |       "args": []
 97 |     }
 98 |   }
 99 | }
100 | ```
101 | 
102 |    </details>
103 | 
104 | ## Usage
105 | 
106 | 1. Ensure the Cinema 4D Socket Server is running.
107 | 2. Open Claude Desktop and look for the hammer icon 🔨 in the input box, indicating MCP tools are available.
108 | 3. Use the available [Tool Commands](#tool-commands) to interact with Cinema 4D through Claude.
109 | 
110 | ## Testing
111 | 
112 | ### Command Line Testing
113 | 
114 | To test the Cinema 4D socket server directly from the command line:
115 | 
116 | ```bash
117 | python main.py
118 | ```
119 | 
120 | You should see output confirming the server's successful start and connection to Cinema 4D.
121 | 
122 | ### Testing with MCP Test Harness
123 | 
124 | The repository includes a simple test harness for running predefined command sequences:
125 | 
126 | 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.
127 | 
128 | 2. **GUI Test Runner** (`tests/mcp_test_harness_gui.py`): A simple Tkinter GUI for running the test commands:
129 | 
130 |    ```bash
131 |    python tests/mcp_test_harness_gui.py
132 |    ```
133 | 
134 |    The GUI allows you to:
135 | 
136 |    - Select a JSONL test file
137 |    - Run the commands in sequence
138 |    - View the responses from Cinema 4D
139 | 
140 | This test harness is particularly useful for:
141 | 
142 | - Rapidly testing new commands
143 | - Verifying plugin functionality after updates
144 | - Recreating complex scenes for debugging
145 | - Testing compatibility across different Cinema 4D versions
146 | 
147 | ## Troubleshooting & Debugging
148 | 
149 | 1. Check the log files:
150 | 
151 |    ```bash
152 |    tail -f ~/Library/Logs/Claude/mcp*.log
153 |    ```
154 | 
155 | 2. Verify Cinema 4D shows connections in its console after you open Claude Desktop.
156 | 
157 | 3. Test the wrapper script directly:
158 | 
159 |    ```bash
160 |    cinema4d-mcp-wrapper
161 |    ```
162 | 
163 | 4. If there are errors finding the mcp module, install it system-wide:
164 | 
165 |    ```bash
166 |    pip install mcp
167 |    ```
168 | 
169 | 5. For advanced debugging, use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
170 |    ```bash
171 |    npx @modelcontextprotocol/inspector uv --directory /Users/username/cinema4d-mcp run cinema4d-mcp
172 |    ```
173 | 
174 | ## Project File Structure
175 | 
176 | ```
177 | cinema4d-mcp/
178 | ├── .gitignore
179 | ├── LICENSE
180 | ├── README.md
181 | ├── main.py
182 | ├── pyproject.toml
183 | ├── setup.py
184 | ├── bin/
185 | │   └── cinema4d-mcp-wrapper
186 | ├── c4d_plugin/
187 | │   └── mcp_server_plugin.pyp
188 | ├── src/
189 | │   └── cinema4d_mcp/
190 | │       ├── __init__.py
191 | │       ├── server.py
192 | │       ├── config.py
193 | │       └── utils.py
194 | └── tests/
195 |     ├── test_server.py
196 |     ├── mcp_test_harness.jsonl
197 |     └── mcp_test_harness_gui.py
198 | ```
199 | 
200 | ## Tool Commands
201 | 
202 | ### General Scene & Execution
203 | 
204 | - `get_scene_info`: Get summary info about the active Cinema 4D scene. ✅
205 | - `list_objects`: List all scene objects (with hierarchy). ✅
206 | - `group_objects`: Group selected objects under a new null. ✅
207 | - `execute_python`: Execute custom Python code inside Cinema 4D. ✅
208 | - `save_scene`: Save the current Cinema 4D project to disk. ✅
209 | - `load_scene`: Load a `.c4d` file into the scene. ✅
210 | - `set_keyframe`: Set a keyframe on an objects property (position, rotation, etc.). ✅
211 | 
212 | ### Object Creation & Modification
213 | 
214 | - `add_primitive`: Add a primitive (cube, sphere, cone, etc.) to the scene. ✅
215 | - `modify_object`: Modify transform or attributes of an existing object. ✅
216 | - `create_abstract_shape`: Create an organic, non-standard abstract form. ✅
217 | 
218 | ### Cameras & Animation
219 | 
220 | - `create_camera`: Add a new camera to the scene. ✅
221 | - `animate_camera`: Animate a camera along a path (linear or spline-based). ✅
222 | 
223 | ### Lighting & Materials
224 | 
225 | - `create_light`: Add a light (omni, spot, etc.) to the scene. ✅
226 | - `create_material`: Create a standard Cinema 4D material. ✅
227 | - `apply_material`: Apply a material to a target object. ✅
228 | - `apply_shader`: Generate and apply a stylized or procedural shader. ✅
229 | 
230 | ### Redshift Support
231 | 
232 | - `validate_redshift_materials`: Check Redshift material setup and connections. ✅ ⚠️ (Redshift materials not fully implemented)
233 | 
234 | ### MoGraph & Fields
235 | 
236 | - `create_mograph_cloner`: Add a MoGraph Cloner (linear, radial, grid, etc.). ✅
237 | - `add_effector`: Add a MoGraph Effector (Random, Plain, etc.). ✅
238 | - `apply_mograph_fields`: Add and link a MoGraph Field to objects. ✅
239 | 
240 | ### Dynamics & Physics
241 | 
242 | - `create_soft_body`: Add a Soft Body tag to an object. ✅
243 | - `apply_dynamics`: Apply Rigid or Soft Body physics. ✅
244 | 
245 | ### Rendering & Preview
246 | 
247 | - `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.)
248 | - `render_preview`: Render a quick preview and return base64 image (for AI). ✅
249 | - `snapshot_scene`: Capture a snapshot of the scene (objects + preview image). ✅
250 | 
251 | ## Compatibility Plan & Roadmap
252 | 
253 | | Cinema 4D Version | Python Version | Compatibility Status | Notes                                             |
254 | | ----------------- | -------------- | -------------------- | ------------------------------------------------- |
255 | | R21 / S22         | Python 2.7     | ❌ Not supported     | Legacy API and Python version too old             |
256 | | R23               | Python 3.7     | 🔍 Not planned       | Not currently tested                              |
257 | | S24 / R25 / S26   | Python 3.9     | ⚠️ Possible (TBD)    | Requires testing and fallbacks for missing APIs   |
258 | | 2023.0 / 2023.1   | Python 3.9     | 🧪 In progress       | Targeting fallback support for core functionality |
259 | | 2023.2            | Python 3.10    | 🧪 In progress       | Aligns with planned testing base                  |
260 | | 2024.0            | Python 3.11    | ✅ Supported         | Verified                                          |
261 | | 2025.0+           | Python 3.11    | ✅ Fully Supported   | Primary development target                        |
262 | 
263 | ### Compatibility Goals
264 | 
265 | - **Short Term**: Ensure compatibility with C4D 2023.1+ (Python 3.9 and 3.10)
266 | - **Mid Term**: Add conditional handling for missing MoGraph and Field APIs
267 | - **Long Term**: Consider optional legacy plugin module for R23–S26 support if demand arises
268 | 
269 | ## Recent Fixes
270 | 
271 | - 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.
272 | - Object Finding: Reworked find_object_by_name to correctly handle GUIDs (numeric string format), fixed recursion errors, and improved reliability when doc.SearchObject fails.
273 | - 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.
274 | - 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.
275 | - 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.
276 | - Registration: Fixed AttributeError for c4d.NilGuid.
277 | 
```

--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------

```python
1 | #!/usr/bin/env python
2 | from setuptools import setup
3 | 
4 | if __name__ == "__main__":
5 |     setup()
```

--------------------------------------------------------------------------------
/src/cinema4d_mcp/config.py:
--------------------------------------------------------------------------------

```python
1 | """Configuration handling for Cinema 4D MCP Server."""
2 | 
3 | import os
4 | 
5 | # Default configuration
6 | C4D_HOST = os.environ.get('C4D_HOST', '127.0.0.1')
7 | C4D_PORT = int(os.environ.get('C4D_PORT', 5555))
```

--------------------------------------------------------------------------------
/src/cinema4d_mcp/__init__.py:
--------------------------------------------------------------------------------

```python
 1 | """Cinema 4D MCP Server - Connect Claude to Cinema 4D"""
 2 | 
 3 | __version__ = "0.1.0"
 4 | 
 5 | from . import server
 6 | 
 7 | def main():
 8 |     """Main entry point for the package."""
 9 |     server.mcp_app.run()
10 | 
11 | def main_wrapper():
12 |     """Entry point for the wrapper script."""
13 |     main()
```

--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------

```toml
 1 | [build-system]
 2 | requires = ["setuptools>=42", "wheel"]
 3 | build-backend = "setuptools.build_meta"
 4 | 
 5 | [project]
 6 | name = "cinema4d-mcp"
 7 | version = "0.1.2"
 8 | description = "Cinema 4D MCP Server for Claude Desktop with MoGraph support"
 9 | authors = [
10 |     {name = "Your Name", email = "[email protected]"}
11 | ]
12 | readme = "README.md"
13 | requires-python = ">=3.9"
14 | dependencies = [
15 |     "mcp>=1.2.0",
16 |     "starlette",
17 | ]
18 | 
19 | [project.scripts]
20 | cinema4d-mcp-wrapper = "cinema4d_mcp:main_wrapper"
21 | cinema4d-mcp = "cinema4d_mcp:main"
```

--------------------------------------------------------------------------------
/src/cinema4d_mcp/utils.py:
--------------------------------------------------------------------------------

```python
 1 | """Utility functions for Cinema 4D MCP Server."""
 2 | 
 3 | import socket
 4 | import sys
 5 | import logging
 6 | 
 7 | # Configure logging
 8 | logging.basicConfig(
 9 |     level=logging.DEBUG,
10 |     format='%(asctime)s [%(levelname)s] %(message)s',
11 |     handlers=[
12 |         logging.StreamHandler(sys.stderr)
13 |     ]
14 | )
15 | 
16 | logger = logging.getLogger("cinema4d-mcp")
17 | 
18 | def check_c4d_connection(host, port):
19 |     """Check if Cinema 4D socket server is running."""
20 |     try:
21 |         sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
22 |         sock.settimeout(5)
23 |         result = sock.connect_ex((host, port))
24 |         sock.close()
25 |         return result == 0
26 |     except Exception as e:
27 |         logger.error(f"Error checking Cinema 4D connection: {e}")
28 |         return False
```

--------------------------------------------------------------------------------
/tests/test_server.py:
--------------------------------------------------------------------------------

```python
 1 | """Tests for the Cinema 4D MCP Server."""
 2 | 
 3 | import unittest
 4 | import socket
 5 | import json
 6 | from unittest.mock import patch, MagicMock
 7 | 
 8 | from cinema4d_mcp.server import send_to_c4d, C4DConnection
 9 | 
10 | class TestC4DServer(unittest.TestCase):
11 |     """Test cases for Cinema 4D server functionality."""
12 | 
13 |     def test_connection_disconnected(self):
14 |         """Test behavior when connection is disconnected."""
15 |         connection = C4DConnection(sock=None, connected=False)
16 |         result = send_to_c4d(connection, {"command": "test"})
17 |         self.assertIn("error", result)
18 |         self.assertEqual(result["error"], "Not connected to Cinema 4D")
19 | 
20 |     @patch('socket.socket')
21 |     def test_send_to_c4d(self, mock_socket):
22 |         """Test sending commands to C4D with a mocked socket."""
23 |         # Setup mock
24 |         mock_instance = MagicMock()
25 |         mock_instance.recv.return_value = b'{"result": "success"}\n'
26 |         mock_socket.return_value = mock_instance
27 |         
28 |         # Create connection with mock socket
29 |         connection = C4DConnection(sock=mock_instance, connected=True)
30 |         
31 |         # Test sending a command
32 |         result = send_to_c4d(connection, {"command": "test"})
33 |         
34 |         # Verify command was sent correctly
35 |         expected_send = b'{"command": "test"}\n'
36 |         mock_instance.sendall.assert_called_once()
37 |         self.assertEqual(result, {"result": "success"})
38 | 
39 |     def test_send_to_c4d_exception(self):
40 |         """Test error handling when sending fails."""
41 |         # Create a socket that raises an exception
42 |         mock_socket = MagicMock()
43 |         mock_socket.sendall.side_effect = Exception("Test error")
44 |         
45 |         connection = C4DConnection(sock=mock_socket, connected=True)
46 |         result = send_to_c4d(connection, {"command": "test"})
47 |         
48 |         self.assertIn("error", result)
49 |         self.assertIn("Test error", result["error"])
50 | 
51 | if __name__ == '__main__':
52 |     unittest.main()
```

--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------

```python
 1 | #!/usr/bin/env python3
 2 | """
 3 | Cinema 4D MCP Server - Main entry point script
 4 | 
 5 | This script starts the Cinema 4D MCP server either directly or through
 6 | package imports, allowing it to be run both as a script and as a module.
 7 | """
 8 | 
 9 | import sys
10 | import os
11 | import socket
12 | import logging
13 | import traceback
14 | 
15 | # Configure logging to stderr
16 | logging.basicConfig(
17 |     level=logging.DEBUG,
18 |     format="%(asctime)s [%(levelname)s] %(message)s",
19 |     handlers=[logging.StreamHandler(sys.stderr)],
20 | )
21 | 
22 | logger = logging.getLogger("cinema4d-mcp")
23 | 
24 | # Add the src directory to the Python path
25 | src_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "src")
26 | if os.path.exists(src_path):
27 |     sys.path.insert(0, src_path)
28 | 
29 | # Add the project root to Python path
30 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
31 | 
32 | 
33 | def log_to_stderr(message):
34 |     """Log a message to stderr for Claude Desktop to capture."""
35 |     print(message, file=sys.stderr, flush=True)
36 | 
37 | 
38 | def main():
39 |     """Main entry point function."""
40 |     log_to_stderr("========== CINEMA 4D MCP SERVER STARTING ==========")
41 |     log_to_stderr(f"Python version: {sys.version}")
42 |     log_to_stderr(f"Current directory: {os.getcwd()}")
43 |     log_to_stderr(f"Python path: {sys.path}")
44 | 
45 |     # Check if Cinema 4D socket is available
46 |     c4d_host = os.environ.get("C4D_HOST", "127.0.0.1")
47 |     c4d_port = int(os.environ.get("C4D_PORT", 5555))
48 | 
49 |     log_to_stderr(f"Checking connection to Cinema 4D on {c4d_host}:{c4d_port}")
50 |     try:
51 |         test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
52 |         test_socket.settimeout(5)  # Set 5 second timeout
53 |         test_socket.connect((c4d_host, c4d_port))
54 |         test_socket.close()
55 |         log_to_stderr("✅ Successfully connected to Cinema 4D socket!")
56 |     except Exception as e:
57 |         log_to_stderr(f"❌ Could not connect to Cinema 4D socket: {e}")
58 |         log_to_stderr(
59 |             "   The server will still start, but Cinema 4D integration won't work!"
60 |         )
61 | 
62 |     try:
63 |         log_to_stderr("Importing cinema4d_mcp...")
64 |         from cinema4d_mcp import main as package_main
65 | 
66 |         log_to_stderr("🚀 Starting Cinema 4D MCP Server...")
67 |         package_main()
68 |     except Exception as e:
69 |         log_to_stderr(f"❌ Error starting server: {e}")
70 |         log_to_stderr(traceback.format_exc())
71 |         sys.exit(1)
72 | 
73 | 
74 | if __name__ == "__main__":
75 |     main()
76 | 
```

--------------------------------------------------------------------------------
/tests/mcp_test_harness_gui.py:
--------------------------------------------------------------------------------

```python
  1 | import socket
  2 | import json
  3 | import time
  4 | import tkinter as tk
  5 | from tkinter import filedialog, messagebox, scrolledtext
  6 | import threading
  7 | import os
  8 | import copy  # Needed for deep copying commands
  9 | 
 10 | SERVER_HOST = "localhost"
 11 | SERVER_PORT = 5555
 12 | 
 13 | 
 14 | class MCPTestHarnessGUI:
 15 |     def __init__(self, root):
 16 |         self.root = root
 17 |         self.root.title("MCP Test Harness")
 18 | 
 19 |         # --- GUI Elements ---
 20 |         self.file_label = tk.Label(root, text="Test File:")
 21 |         self.file_label.grid(row=0, column=0, sticky="w")
 22 |         self.file_entry = tk.Entry(root, width=50)
 23 |         self.file_entry.grid(row=0, column=1)
 24 |         self.browse_button = tk.Button(root, text="Browse", command=self.browse_file)
 25 |         self.browse_button.grid(row=0, column=2)
 26 |         self.run_button = tk.Button(root, text="Run Test", command=self.run_test_thread)
 27 |         self.run_button.grid(row=1, column=1, pady=10)
 28 |         self.log_text = scrolledtext.ScrolledText(
 29 |             root, wrap=tk.WORD, width=80, height=30
 30 |         )
 31 |         self.log_text.grid(row=2, column=0, columnspan=3)
 32 | 
 33 |         # --- ADDED: Storage for GUIDs ---
 34 |         self.guid_map = {}  # Maps requested_name -> actual_guid
 35 | 
 36 |     def browse_file(self):
 37 |         filename = filedialog.askopenfilename(
 38 |             filetypes=[("JSON Lines", "*.jsonl"), ("All Files", "*.*")]
 39 |         )
 40 |         if filename:
 41 |             self.file_entry.delete(0, tk.END)
 42 |             self.file_entry.insert(0, filename)
 43 | 
 44 |     def log(self, message):
 45 |         # --- Modified to handle updates from thread ---
 46 |         def update_log():
 47 |             self.log_text.insert(tk.END, message + "\n")
 48 |             self.log_text.see(tk.END)
 49 | 
 50 |         # Schedule the update in the main Tkinter thread
 51 |         self.root.after(0, update_log)
 52 |         print(message)  # Also print to console
 53 | 
 54 |     def run_test_thread(self):
 55 |         # Disable run button during test
 56 |         self.run_button.config(state=tk.DISABLED)
 57 |         # Clear log and GUID map for new run
 58 |         self.log_text.delete(1.0, tk.END)
 59 |         self.guid_map = {}
 60 |         # Start the test in a separate thread
 61 |         threading.Thread(target=self.run_test, daemon=True).start()
 62 | 
 63 |     # --- ADDED: Recursive substitution function ---
 64 |     def substitute_placeholders(self, data_structure):
 65 |         """Recursively substitutes known names with GUIDs in dicts and lists."""
 66 |         if isinstance(data_structure, dict):
 67 |             new_dict = {}
 68 |             for key, value in data_structure.items():
 69 |                 # Substitute the value if it's a string matching a known name
 70 |                 if isinstance(value, str) and value in self.guid_map:
 71 |                     new_dict[key] = self.guid_map[value]
 72 |                     self.log(
 73 |                         f"    Substituted '{key}': '{value}' -> '{self.guid_map[value]}'"
 74 |                     )
 75 |                 # Recursively process nested structures
 76 |                 elif isinstance(value, (dict, list)):
 77 |                     new_dict[key] = self.substitute_placeholders(value)
 78 |                 else:
 79 |                     new_dict[key] = value
 80 |             return new_dict
 81 |         elif isinstance(data_structure, list):
 82 |             new_list = []
 83 |             for item in data_structure:
 84 |                 # Substitute the item if it's a string matching a known name
 85 |                 if isinstance(item, str) and item in self.guid_map:
 86 |                     new_list.append(self.guid_map[item])
 87 |                     self.log(
 88 |                         f"    Substituted item in list: '{item}' -> '{self.guid_map[item]}'"
 89 |                     )
 90 |                 # Recursively process nested structures
 91 |                 elif isinstance(item, (dict, list)):
 92 |                     new_list.append(self.substitute_placeholders(item))
 93 |                 else:
 94 |                     new_list.append(item)
 95 |             return new_list
 96 |         else:
 97 |             # If it's not a dict or list, check if the value itself needs substitution
 98 |             if isinstance(data_structure, str) and data_structure in self.guid_map:
 99 |                 substituted_value = self.guid_map[data_structure]
100 |                 self.log(
101 |                     f"    Substituted value: '{data_structure}' -> '{substituted_value}'"
102 |                 )
103 |                 return substituted_value
104 |             return data_structure  # Return other types unchanged
105 | 
106 |     def run_test(self):
107 |         test_file = self.file_entry.get()
108 |         if not os.path.exists(test_file):
109 |             messagebox.showerror("Error", "Test file does not exist.")
110 |             self.run_button.config(state=tk.NORMAL)  # Re-enable button
111 |             return
112 | 
113 |         try:
114 |             with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
115 |                 self.log(f"Connecting to MCP server at {SERVER_HOST}:{SERVER_PORT}...")
116 |                 sock.connect((SERVER_HOST, SERVER_PORT))
117 |                 self.log("Connected ✅\n--- Test Start ---")
118 | 
119 |                 with open(test_file, "r", encoding="utf-8") as f:
120 |                     for line_num, line in enumerate(f, 1):
121 |                         if not line.strip():
122 |                             continue  # Skip empty lines
123 | 
124 |                         try:
125 |                             # Load original command from file
126 |                             original_command = json.loads(line.strip())
127 |                             self.log(
128 |                                 f"\n▶️ Command {line_num} (Original): {json.dumps(original_command)}"
129 |                             )
130 | 
131 |                             # --- MODIFIED: Substitute placeholders before sending ---
132 |                             # Deep copy to avoid modifying the original dict before logging
133 |                             command_to_send = copy.deepcopy(original_command)
134 |                             command_to_send = self.substitute_placeholders(
135 |                                 command_to_send
136 |                             )
137 | 
138 |                             if command_to_send != original_command:
139 |                                 self.log(
140 |                                     f"   Command {line_num} (Substituted): {json.dumps(command_to_send)}"
141 |                                 )
142 | 
143 |                             # Send the potentially modified command
144 |                             sock.sendall(
145 |                                 (json.dumps(command_to_send) + "\n").encode("utf-8")
146 |                             )
147 | 
148 |                             # Receive response (increase buffer size significantly for base64 previews)
149 |                             response_data = b""
150 |                             sock.settimeout(120.0)  # Set generous timeout for receiving
151 |                             while True:
152 |                                 try:
153 |                                     chunk = sock.recv(32768)  # Read larger chunks
154 |                                     if not chunk:
155 |                                         # Connection closed prematurely?
156 |                                         if not response_data:
157 |                                             raise ConnectionAbortedError(
158 |                                                 "Server closed connection unexpectedly before sending response."
159 |                                             )
160 |                                         break  # No more data
161 |                                     response_data += chunk
162 |                                     # Basic check for newline delimiter, might need refinement for large data
163 |                                     if b"\n" in chunk:
164 |                                         break
165 |                                 except socket.timeout:
166 |                                     # Check if we received *any* data before timeout
167 |                                     if not response_data:
168 |                                         raise TimeoutError(
169 |                                             f"Timeout waiting for response to Command {line_num}"
170 |                                         )
171 |                                     else:
172 |                                         self.log(
173 |                                             f"    Warning: Socket timeout, but received partial data ({len(response_data)} bytes). Assuming complete."
174 |                                         )
175 |                                         break  # Process what we got
176 |                             sock.settimeout(None)  # Reset timeout
177 | 
178 |                             # Decode and parse response
179 |                             response_text = response_data.decode("utf-8").strip()
180 |                             if not response_text:
181 |                                 raise ValueError("Received empty response from server.")
182 | 
183 |                             decoded_response = json.loads(response_text)
184 | 
185 |                             # Log the response (truncate potentially huge base64 data)
186 |                             loggable_response = copy.deepcopy(decoded_response)
187 |                             if isinstance(loggable_response, dict):
188 |                                 # Check common keys for base64 data and truncate
189 |                                 for key in ["image_base64", "image_data"]:
190 |                                     if (
191 |                                         key in loggable_response
192 |                                         and isinstance(loggable_response[key], str)
193 |                                         and len(loggable_response[key]) > 100
194 |                                     ):
195 |                                         loggable_response[key] = (
196 |                                             loggable_response[key][:50]
197 |                                             + "... [truncated]"
198 |                                         )
199 |                                 # Also check within nested 'render' dict for snapshot
200 |                                 if "render" in loggable_response and isinstance(
201 |                                     loggable_response["render"], dict
202 |                                 ):
203 |                                     for key in ["image_base64", "image_data"]:
204 |                                         render_dict = loggable_response["render"]
205 |                                         if (
206 |                                             key in render_dict
207 |                                             and isinstance(render_dict[key], str)
208 |                                             and len(render_dict[key]) > 100
209 |                                         ):
210 |                                             render_dict[key] = (
211 |                                                 render_dict[key][:50]
212 |                                                 + "... [truncated]"
213 |                                             )
214 | 
215 |                             self.log(
216 |                                 f"✅ Response {line_num}: {json.dumps(loggable_response, indent=2)}"
217 |                             )
218 | 
219 |                             # --- ADDED: Capture GUID from response ---
220 |                             if isinstance(decoded_response, dict):
221 |                                 # Check common patterns for created objects
222 |                                 context_keys = [
223 |                                     "object",
224 |                                     "light",
225 |                                     "camera",
226 |                                     "material",
227 |                                     "cloner",
228 |                                     "effector",
229 |                                     "field",
230 |                                     "shape",
231 |                                     "group",
232 |                                 ]
233 |                                 for key in context_keys:
234 |                                     if key in decoded_response and isinstance(
235 |                                         decoded_response[key], dict
236 |                                     ):
237 |                                         obj_info = decoded_response[key]
238 |                                         req_name = obj_info.get("requested_name")
239 |                                         guid = obj_info.get("guid")
240 |                                         act_name = obj_info.get("actual_name")
241 |                                         if req_name and guid:
242 |                                             self.guid_map[req_name] = guid
243 |                                             self.log(
244 |                                                 f"    Captured GUID: '{req_name}' -> {guid} (Actual name: '{act_name}')"
245 |                                             )
246 |                                             # Also map actual name if different, preferring requested name if collision
247 |                                             if (
248 |                                                 act_name
249 |                                                 and act_name != req_name
250 |                                                 and act_name not in self.guid_map
251 |                                             ):
252 |                                                 self.guid_map[act_name] = guid
253 |                                                 self.log(
254 |                                                     f"    Mapped actual name: '{act_name}' -> {guid}"
255 |                                                 )
256 |                                         break  # Assume only one primary object context per response
257 | 
258 |                             # Brief pause between commands
259 |                             time.sleep(0.1)
260 | 
261 |                         except json.JSONDecodeError as e:
262 |                             self.log(f"❌ Error decoding JSON for line {line_num}: {e}")
263 |                             self.log(f"   Raw line: {line.strip()}")
264 |                             break  # Stop test on error
265 |                         except Exception as cmd_e:
266 |                             self.log(f"❌ Error processing command {line_num}: {cmd_e}")
267 |                             import traceback
268 | 
269 |                             self.log(traceback.format_exc())
270 |                             break  # Stop test on error
271 | 
272 |                 self.log("--- Test End ---")
273 | 
274 |         except ConnectionRefusedError:
275 |             self.log(
276 |                 f"❌ Connection Refused: Ensure C4D plugin server is running on {SERVER_HOST}:{SERVER_PORT}."
277 |             )
278 |             messagebox.showerror(
279 |                 "Connection Error",
280 |                 "Connection Refused. Is the Cinema 4D plugin server running?",
281 |             )
282 |         except socket.timeout:
283 |             self.log(
284 |                 f"❌ Connection Timeout: Could not connect to {SERVER_HOST}:{SERVER_PORT}."
285 |             )
286 |             messagebox.showerror("Connection Error", "Connection Timeout.")
287 |         except Exception as e:
288 |             self.log(f"❌ Unexpected Error: {str(e)}")
289 |             import traceback
290 | 
291 |             self.log(traceback.format_exc())
292 |             messagebox.showerror("Error", f"An unexpected error occurred:\n{str(e)}")
293 |         finally:
294 |             # Re-enable run button after test finishes or errors out
295 |             self.root.after(0, lambda: self.run_button.config(state=tk.NORMAL))
296 | 
297 | 
298 | if __name__ == "__main__":
299 |     root = tk.Tk()
300 |     app = MCPTestHarnessGUI(root)
301 |     root.mainloop()
302 | 
```

--------------------------------------------------------------------------------
/src/cinema4d_mcp/server.py:
--------------------------------------------------------------------------------

```python
   1 | """Cinema 4D MCP Server."""
   2 | 
   3 | import socket
   4 | import json
   5 | import os
   6 | import math
   7 | import time
   8 | from dataclasses import dataclass
   9 | from typing import Any, Dict, List, Optional, Union
  10 | from contextlib import asynccontextmanager
  11 | 
  12 | from mcp.server.fastmcp import FastMCP, Context
  13 | from starlette.routing import Route
  14 | from starlette.responses import JSONResponse
  15 | 
  16 | from .config import C4D_HOST, C4D_PORT
  17 | from .utils import logger, check_c4d_connection
  18 | 
  19 | 
  20 | @dataclass
  21 | class C4DConnection:
  22 |     sock: Optional[socket.socket] = None
  23 |     connected: bool = False
  24 | 
  25 | 
  26 | # Asynchronous context manager for Cinema 4D connection
  27 | @asynccontextmanager
  28 | async def c4d_connection_context():
  29 |     """Asynchronous context manager for Cinema 4D connection."""
  30 |     connection = C4DConnection()
  31 |     try:
  32 |         # Initialize connection to Cinema 4D
  33 |         sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  34 |         sock.connect((C4D_HOST, C4D_PORT))
  35 |         connection.sock = sock
  36 |         connection.connected = True
  37 |         logger.info(f"✅ Connected to Cinema 4D at {C4D_HOST}:{C4D_PORT}")
  38 |         yield connection  # Yield the connection
  39 |     except Exception as e:
  40 |         logger.error(f"❌ Failed to connect to Cinema 4D: {str(e)}")
  41 |         connection.connected = False  # Ensure connection is marked as not connected
  42 |         yield connection  # Still yield the connection object
  43 |     finally:
  44 |         # Clean up on server shutdown
  45 |         if connection.sock:
  46 |             connection.sock.close()
  47 |             logger.info("🔌 Disconnected from Cinema 4D")
  48 | 
  49 | 
  50 | def send_to_c4d(connection: C4DConnection, command: Dict[str, Any]) -> Dict[str, Any]:
  51 |     """Send a command to Cinema 4D and get the response with improved timeout handling."""
  52 |     if not connection.connected or not connection.sock:
  53 |         return {"error": "Not connected to Cinema 4D"}
  54 | 
  55 |     # Set appropriate timeout based on command type
  56 |     command_type = command.get("command", "")
  57 | 
  58 |     # Long-running operations need longer timeouts
  59 |     if command_type in ["render_frame", "apply_mograph_fields"]:
  60 |         timeout = 120  # 2 minutes for render operations
  61 |         logger.info(f"Using extended timeout ({timeout}s) for {command_type}")
  62 |     else:
  63 |         timeout = 20  # Default timeout for regular operations
  64 | 
  65 |     try:
  66 |         # Convert command to JSON and send it
  67 |         command_json = json.dumps(command) + "\n"  # Add newline as message delimiter
  68 |         logger.debug(f"Sending command: {command_type}")
  69 |         connection.sock.sendall(command_json.encode("utf-8"))
  70 | 
  71 |         # Set socket timeout
  72 |         connection.sock.settimeout(timeout)
  73 | 
  74 |         # Receive response
  75 |         response_data = b""
  76 |         start_time = time.time()
  77 |         max_time = start_time + timeout
  78 | 
  79 |         # Log for long-running operations
  80 |         if command_type in ["render_frame", "apply_mograph_fields"]:
  81 |             logger.info(
  82 |                 f"Waiting for response from {command_type} (timeout: {timeout}s)"
  83 |             )
  84 | 
  85 |         while time.time() < max_time:
  86 |             try:
  87 |                 chunk = connection.sock.recv(4096)
  88 |                 if not chunk:
  89 |                     # If we receive an empty chunk, the connection might be closed
  90 |                     if not response_data:
  91 |                         logger.error(
  92 |                             f"Connection closed by Cinema 4D during {command_type}"
  93 |                         )
  94 |                         return {
  95 |                             "error": f"Connection closed by Cinema 4D during {command_type}"
  96 |                         }
  97 |                     break
  98 | 
  99 |                 response_data += chunk
 100 | 
 101 |                 # For long operations, log progress on data receipt
 102 |                 elapsed = time.time() - start_time
 103 |                 if (
 104 |                     command_type in ["render_frame", "apply_mograph_fields"]
 105 |                     and elapsed > 5
 106 |                 ):
 107 |                     logger.debug(
 108 |                         f"Received partial data for {command_type} ({len(response_data)} bytes, {elapsed:.1f}s elapsed)"
 109 |                     )
 110 | 
 111 |                 if b"\n" in chunk:  # Message complete when we see a newline
 112 |                     logger.debug(f"Received complete response for {command_type}")
 113 |                     break
 114 | 
 115 |             except socket.timeout:
 116 |                 logger.error(f"Socket timeout while receiving data for {command_type}")
 117 |                 return {
 118 |                     "error": f"Timeout waiting for response from Cinema 4D ({timeout}s) for {command_type}"
 119 |                 }
 120 | 
 121 |         # Parse and return response
 122 |         if not response_data:
 123 |             logger.error(f"No response received from Cinema 4D for {command_type}")
 124 |             return {"error": f"No response received from Cinema 4D for {command_type}"}
 125 | 
 126 |         response_text = response_data.decode("utf-8").strip()
 127 | 
 128 |         try:
 129 |             return json.loads(response_text)
 130 |         except json.JSONDecodeError as e:
 131 |             # If JSON parsing fails, log the exact response for debugging
 132 |             logger.error(f"Failed to parse JSON response: {str(e)}")
 133 |             logger.error(f"Raw response (first 200 chars): {response_text[:200]}...")
 134 |             return {"error": f"Invalid response from Cinema 4D: {str(e)}"}
 135 | 
 136 |     except socket.timeout:
 137 |         logger.error(f"Socket timeout during {command_type} ({timeout}s)")
 138 |         return {
 139 |             "error": f"Timeout communicating with Cinema 4D ({timeout}s) for {command_type}"
 140 |         }
 141 |     except Exception as e:
 142 |         logger.error(f"Communication error during {command_type}: {str(e)}")
 143 |         return {"error": f"Communication error: {str(e)}"}
 144 | 
 145 | 
 146 | async def homepage(request):
 147 |     """Handle homepage requests to check if server is running."""
 148 |     c4d_available = check_c4d_connection(C4D_HOST, C4D_PORT)
 149 |     return JSONResponse(
 150 |         {
 151 |             "status": "ok",
 152 |             "cinema4d_connected": c4d_available,
 153 |             "host": C4D_HOST,
 154 |             "port": C4D_PORT,
 155 |         }
 156 |     )
 157 | 
 158 | 
 159 | # Initialize our FastMCP server
 160 | mcp = FastMCP(title="Cinema4D", routes=[Route("/", endpoint=homepage)])
 161 | 
 162 | 
 163 | @mcp.tool()
 164 | async def get_scene_info(ctx: Context) -> str:
 165 |     """Get information about the current Cinema 4D scene."""
 166 |     async with c4d_connection_context() as connection:
 167 |         if not connection.connected:
 168 |             return "❌ Not connected to Cinema 4D"
 169 | 
 170 |         response = send_to_c4d(connection, {"command": "get_scene_info"})
 171 | 
 172 |         if "error" in response:
 173 |             return f"❌ Error: {response['error']}"
 174 | 
 175 |         # Format scene info nicely
 176 |         scene_info = response.get("scene_info", {})
 177 |         return f"""
 178 | # Cinema 4D Scene Information
 179 | - **Filename**: {scene_info.get('filename', 'Untitled')}
 180 | - **Objects**: {scene_info.get('object_count', 0)}
 181 | - **Polygons**: {scene_info.get('polygon_count', 0):,}
 182 | - **Materials**: {scene_info.get('material_count', 0)}
 183 | - **Current Frame**: {scene_info.get('current_frame', 0)}
 184 | - **FPS**: {scene_info.get('fps', 30)}
 185 | - **Frame Range**: {scene_info.get('frame_start', 0)} - {scene_info.get('frame_end', 90)}
 186 | """
 187 | 
 188 | 
 189 | @mcp.tool()
 190 | async def add_primitive(
 191 |     primitive_type: str,
 192 |     name: Optional[str] = None,
 193 |     position: Optional[List[float]] = None,
 194 |     size: Optional[List[float]] = None,
 195 |     ctx: Context = None,
 196 | ) -> str:
 197 |     """
 198 |     Add a primitive object to the Cinema 4D scene.
 199 | 
 200 |     Args:
 201 |         primitive_type: Type of primitive (cube, sphere, cone, cylinder, plane, etc.)
 202 |         name: Optional name for the new object
 203 |         position: Optional [x, y, z] position
 204 |         size: Optional [x, y, z] size or dimensions
 205 |     """
 206 |     async with c4d_connection_context() as connection:
 207 |         if not connection.connected:
 208 |             return "❌ Not connected to Cinema 4D"
 209 | 
 210 |         # Prepare command
 211 |         command = {
 212 |             "command": "add_primitive",
 213 |             "type": primitive_type,
 214 |         }
 215 | 
 216 |         if name:
 217 |             command["object_name"] = name
 218 |         if position:
 219 |             command["position"] = position
 220 |         if size:
 221 |             command["size"] = size
 222 | 
 223 |         # Send command to Cinema 4D
 224 |         response = send_to_c4d(connection, command)
 225 | 
 226 |         if "error" in response:
 227 |             return f"❌ Error: {response['error']}"
 228 | 
 229 |         object_info = response.get("object", {})
 230 |         return response
 231 | 
 232 | 
 233 | @mcp.tool()
 234 | async def modify_object(
 235 |     object_name: str, properties: Dict[str, Any], ctx: Context
 236 | ) -> str:
 237 |     """
 238 |     Modify properties of an existing object.
 239 | 
 240 |     Args:
 241 |         object_name: Name of the object to modify
 242 |         properties: Dictionary of properties to modify (position, rotation, scale, etc.)
 243 |     """
 244 |     async with c4d_connection_context() as connection:
 245 |         if not connection.connected:
 246 |             return "❌ Not connected to Cinema 4D"
 247 | 
 248 |         # Send command to Cinema 4D
 249 |         response = send_to_c4d(
 250 |             connection,
 251 |             {
 252 |                 "command": "modify_object",
 253 |                 "object_name": object_name,
 254 |                 "properties": properties,
 255 |             },
 256 |         )
 257 | 
 258 |         if "error" in response:
 259 |             return f"❌ Error: {response['error']}"
 260 | 
 261 |         # Generate summary of what was modified
 262 |         modified_props = []
 263 |         for prop, value in properties.items():
 264 |             modified_props.append(f"- **{prop}**: {value}")
 265 | 
 266 |         return response
 267 | 
 268 | 
 269 | @mcp.tool()
 270 | async def list_objects(ctx: Context) -> str:
 271 |     """List all objects in the current Cinema 4D scene."""
 272 |     async with c4d_connection_context() as connection:
 273 |         if not connection.connected:
 274 |             return "❌ Not connected to Cinema 4D"
 275 | 
 276 |         response = send_to_c4d(connection, {"command": "list_objects"})
 277 | 
 278 |         if "error" in response:
 279 |             return f"❌ Error: {response['error']}"
 280 | 
 281 |         objects = response.get("objects", [])
 282 |         if not objects:
 283 |             return "No objects found in the scene."
 284 | 
 285 |         # Format objects as a hierarchical list with indentation
 286 |         object_list = []
 287 |         for obj in objects:
 288 |             # Calculate indentation based on object's depth in hierarchy
 289 |             indent = "  " * obj.get("depth", 0)
 290 |             object_list.append(f"{indent}- **{obj['name']}** ({obj['type']})")
 291 | 
 292 |         return response
 293 | 
 294 | 
 295 | @mcp.tool()
 296 | async def create_material(
 297 |     name: str,
 298 |     color: Optional[List[float]] = None,
 299 |     properties: Optional[Dict[str, Any]] = None,
 300 |     ctx: Context = None,
 301 | ) -> str:
 302 |     """
 303 |     Create a new material in Cinema 4D.
 304 | 
 305 |     Args:
 306 |         name: Name for the new material
 307 |         color: Optional [R, G, B] color (values 0-1)
 308 |         properties: Optional additional material properties
 309 |     """
 310 |     async with c4d_connection_context() as connection:
 311 |         if not connection.connected:
 312 |             return "❌ Not connected to Cinema 4D"
 313 | 
 314 |         # Prepare command
 315 |         command = {"command": "create_material", "material_name": name}
 316 | 
 317 |         if color:
 318 |             command["color"] = color
 319 |         if properties:
 320 |             command["properties"] = properties
 321 | 
 322 |         # Send command to Cinema 4D
 323 |         response = send_to_c4d(connection, command)
 324 | 
 325 |         if "error" in response:
 326 |             return f"❌ Error: {response['error']}"
 327 | 
 328 |         material_info = response.get("material", {})
 329 |         return response
 330 | 
 331 | 
 332 | @mcp.tool()
 333 | async def apply_material(material_name: str, object_name: str, ctx: Context) -> str:
 334 |     """
 335 |     Apply a material to an object.
 336 | 
 337 |     Args:
 338 |         material_name: Name of the material to apply
 339 |         object_name: Name of the object to apply the material to
 340 |     """
 341 |     async with c4d_connection_context() as connection:
 342 |         if not connection.connected:
 343 |             return "❌ Not connected to Cinema 4D"
 344 | 
 345 |         # Send command to Cinema 4D
 346 |         response = send_to_c4d(
 347 |             connection,
 348 |             {
 349 |                 "command": "apply_material",
 350 |                 "material_name": material_name,
 351 |                 "object_name": object_name,
 352 |             },
 353 |         )
 354 | 
 355 |         if "error" in response:
 356 |             return f"❌ Error: {response['error']}"
 357 | 
 358 |         return response
 359 | 
 360 | 
 361 | @mcp.tool()
 362 | async def render_frame(
 363 |     output_path: Optional[str] = None,
 364 |     width: Optional[int] = None,
 365 |     height: Optional[int] = None,
 366 |     ctx: Context = None,
 367 | ) -> str:
 368 |     """
 369 |     Render the current frame.
 370 | 
 371 |     Args:
 372 |         output_path: Optional path to save the rendered image
 373 |         width: Optional render width in pixels
 374 |         height: Optional render height in pixels
 375 |     """
 376 |     async with c4d_connection_context() as connection:
 377 |         if not connection.connected:
 378 |             return "❌ Not connected to Cinema 4D"
 379 | 
 380 |         # Prepare command
 381 |         command = {"command": "render_frame"}
 382 | 
 383 |         if output_path:
 384 |             command["output_path"] = output_path
 385 |         if width:
 386 |             command["width"] = width
 387 |         if height:
 388 |             command["height"] = height
 389 | 
 390 |         # Send command to Cinema 4D
 391 |         response = send_to_c4d(connection, command)
 392 | 
 393 |         if "error" in response:
 394 |             return f"❌ Error: {response['error']}"
 395 | 
 396 |         render_info = response.get("render_info", {})
 397 |         return response
 398 | 
 399 | 
 400 | @mcp.tool()
 401 | async def set_keyframe(
 402 |     object_name: str, property_name: str, value: Any, frame: int, ctx: Context
 403 | ) -> str:
 404 |     """
 405 |     Set a keyframe for an object property.
 406 | 
 407 |     Args:
 408 |         object_name: Name of the object
 409 |         property_name: Name of the property to keyframe (e.g., 'position.x')
 410 |         value: Value to set at the keyframe
 411 |         frame: Frame number to set the keyframe at
 412 |     """
 413 |     async with c4d_connection_context() as connection:
 414 |         if not connection.connected:
 415 |             return "❌ Not connected to Cinema 4D"
 416 | 
 417 |         # Send command to Cinema 4D
 418 |         response = send_to_c4d(
 419 |             connection,
 420 |             {
 421 |                 "command": "set_keyframe",
 422 |                 "object_name": object_name,
 423 |                 "property_name": property_name,
 424 |                 "value": value,
 425 |                 "frame": frame,
 426 |             },
 427 |         )
 428 | 
 429 |         if "error" in response:
 430 |             return f"❌ Error: {response['error']}"
 431 | 
 432 |         return response
 433 | 
 434 | 
 435 | @mcp.tool()
 436 | async def save_scene(file_path: Optional[str] = None, ctx: Context = None) -> str:
 437 |     """
 438 |     Save the current Cinema 4D scene.
 439 | 
 440 |     Args:
 441 |         file_path: Optional path to save the scene to
 442 |     """
 443 |     async with c4d_connection_context() as connection:
 444 |         if not connection.connected:
 445 |             return "❌ Not connected to Cinema 4D"
 446 | 
 447 |         # Prepare command
 448 |         command = {"command": "save_scene"}
 449 | 
 450 |         if file_path:
 451 |             command["file_path"] = file_path
 452 | 
 453 |         # Send command to Cinema 4D
 454 |         response = send_to_c4d(connection, command)
 455 | 
 456 |         if "error" in response:
 457 |             return f"❌ Error: {response['error']}"
 458 | 
 459 |         return response
 460 | 
 461 | 
 462 | @mcp.tool()
 463 | async def load_scene(file_path: str, ctx: Context) -> str:
 464 |     """
 465 |     Load a Cinema 4D scene file.
 466 | 
 467 |     Args:
 468 |         file_path: Path to the scene file to load
 469 |     """
 470 |     async with c4d_connection_context() as connection:
 471 |         if not connection.connected:
 472 |             return "❌ Not connected to Cinema 4D"
 473 | 
 474 |         # Send command to Cinema 4D
 475 |         response = send_to_c4d(
 476 |             connection, {"command": "load_scene", "file_path": file_path}
 477 |         )
 478 | 
 479 |         if "error" in response:
 480 |             return f"❌ Error: {response['error']}"
 481 | 
 482 |         return response
 483 | 
 484 | 
 485 | @mcp.tool()
 486 | async def create_mograph_cloner(
 487 |     cloner_type: str, name: Optional[str] = None, ctx: Context = None
 488 | ) -> str:
 489 |     """
 490 |     Create a MoGraph Cloner object of specified type.
 491 | 
 492 |     Args:
 493 |         cloner_type: Type of cloner (grid, radial, linear)
 494 |         name: Optional name for the cloner
 495 |     """
 496 |     async with c4d_connection_context() as connection:
 497 |         if not connection.connected:
 498 |             return "❌ Not connected to Cinema 4D"
 499 | 
 500 |         command = {"command": "create_mograph_cloner", "mode": cloner_type}
 501 | 
 502 |         if name:
 503 |             command["cloner_name"] = name
 504 | 
 505 |         response = send_to_c4d(connection, command)
 506 | 
 507 |         if "error" in response:
 508 |             return f"❌ Error: {response['error']}"
 509 | 
 510 |         object_info = response.get("object", {})
 511 |         return response
 512 | 
 513 | 
 514 | @mcp.tool()
 515 | async def add_effector(
 516 |     effector_type: str,
 517 |     name: Optional[str] = None,
 518 |     target: Optional[str] = None,
 519 |     ctx: Context = None,
 520 | ) -> str:
 521 |     """
 522 |     Add a MoGraph Effector to the scene.
 523 | 
 524 |     Args:
 525 |         effector_type: Type of effector (random, shader, field)
 526 |         name: Optional name for the effector
 527 |         target: Optional target object (e.g., cloner) to apply the effector to
 528 |     """
 529 |     async with c4d_connection_context() as connection:
 530 |         if not connection.connected:
 531 |             return "❌ Not connected to Cinema 4D"
 532 | 
 533 |         command = {"command": "add_effector", "effector_type": effector_type}
 534 | 
 535 |         if name:
 536 |             command["effector_name"] = name
 537 | 
 538 |         if target:
 539 |             command["cloner_name"] = target
 540 | 
 541 |         response = send_to_c4d(connection, command)
 542 | 
 543 |         if "error" in response:
 544 |             return f"❌ Error: {response['error']}"
 545 | 
 546 |         object_info = response.get("object", {})
 547 |         return response
 548 | 
 549 | 
 550 | @mcp.tool()
 551 | async def apply_mograph_fields(
 552 |     field_type: str,
 553 |     target: Optional[str] = None,
 554 |     field_name: Optional[str] = None,
 555 |     parameters: Optional[Dict[str, Any]] = None,
 556 |     ctx: Context = None,
 557 | ) -> str:
 558 |     """
 559 |     Create and apply a MoGraph Field.
 560 | 
 561 |     Args:
 562 |         field_type: Type of field (spherical, box, cylindrical, linear, radial, noise)
 563 |         target: Optional target object to apply the field to
 564 |         field_name: Optional name for the field
 565 |         parameters: Optional parameters for the field (strength, falloff)
 566 |     """
 567 |     async with c4d_connection_context() as connection:
 568 |         if not connection.connected:
 569 |             return "❌ Not connected to Cinema 4D"
 570 | 
 571 |         # Build the command with required parameters
 572 |         command = {"command": "apply_mograph_fields", "field_type": field_type}
 573 | 
 574 |         # Add optional parameters
 575 |         if target:
 576 |             command["target_name"] = target
 577 | 
 578 |         if field_name:
 579 |             command["field_name"] = field_name
 580 | 
 581 |         if parameters:
 582 |             command["parameters"] = parameters
 583 | 
 584 |         # Log the command for debugging
 585 |         logger.info(f"Sending apply_mograph_fields command: {command}")
 586 | 
 587 |         # Send the command to Cinema 4D
 588 |         response = send_to_c4d(connection, command)
 589 | 
 590 |         # Handle error responses
 591 |         if "error" in response:
 592 |             error_msg = response["error"]
 593 |             logger.error(f"Error applying field: {error_msg}")
 594 |             return f"❌ Error: {error_msg}"
 595 | 
 596 |         # Extract field info from response
 597 |         field_info = response.get("field", {})
 598 | 
 599 |         # Build a response message
 600 |         field_name = field_info.get("name", f"{field_type.capitalize()} Field")
 601 |         applied_to = field_info.get("applied_to", "None")
 602 | 
 603 |         # Additional parameters if available
 604 |         params_info = ""
 605 |         if "strength" in field_info:
 606 |             params_info += f"\n- **Strength**: {field_info.get('strength')}"
 607 |         if "falloff" in field_info:
 608 |             params_info += f"\n- **Falloff**: {field_info.get('falloff')}"
 609 | 
 610 |         return response
 611 | 
 612 | 
 613 | @mcp.tool()
 614 | async def create_soft_body(object_name: str, ctx: Context = None) -> str:
 615 |     """
 616 |     Add soft body dynamics to the specified object.
 617 | 
 618 |     Args:
 619 |         object_name: Name of the object to convert to a soft body
 620 |     """
 621 |     async with c4d_connection_context() as connection:
 622 |         if not connection.connected:
 623 |             return "❌ Not connected to Cinema 4D"
 624 | 
 625 |         response = send_to_c4d(
 626 |             connection, {"command": "create_soft_body", "object_name": object_name}
 627 |         )
 628 | 
 629 |         if "error" in response:
 630 |             return f"❌ Error: {response['error']}"
 631 | 
 632 |         return response
 633 | 
 634 | 
 635 | @mcp.tool()
 636 | async def apply_dynamics(
 637 |     object_name: str, dynamics_type: str, ctx: Context = None
 638 | ) -> str:
 639 |     """
 640 |     Add dynamics (rigid or soft) to the specified object.
 641 | 
 642 |     Args:
 643 |         object_name: Name of the object to apply dynamics to
 644 |         dynamics_type: Type of dynamics to apply (rigid, soft)
 645 |     """
 646 |     async with c4d_connection_context() as connection:
 647 |         if not connection.connected:
 648 |             return "❌ Not connected to Cinema 4D"
 649 | 
 650 |         response = send_to_c4d(
 651 |             connection,
 652 |             {
 653 |                 "command": "apply_dynamics",
 654 |                 "object_name": object_name,
 655 |                 "type": dynamics_type,
 656 |             },
 657 |         )
 658 | 
 659 |         if "error" in response:
 660 |             return f"❌ Error: {response['error']}"
 661 | 
 662 |         return response
 663 | 
 664 | 
 665 | @mcp.tool()
 666 | async def create_abstract_shape(
 667 |     shape_type: str, name: Optional[str] = None, ctx: Context = None
 668 | ) -> str:
 669 |     """
 670 |     Create an organic, abstract shape.
 671 | 
 672 |     Args:
 673 |         shape_type: Type of shape (blob, metaball)
 674 |         name: Optional name for the shape
 675 |     """
 676 |     async with c4d_connection_context() as connection:
 677 |         if not connection.connected:
 678 |             return "❌ Not connected to Cinema 4D"
 679 | 
 680 |         command = {"command": "create_abstract_shape", "shape_type": shape_type}
 681 | 
 682 |         if name:
 683 |             command["object_name"] = name
 684 | 
 685 |         response = send_to_c4d(connection, command)
 686 | 
 687 |         if "error" in response:
 688 |             return f"❌ Error: {response['error']}"
 689 | 
 690 |         object_info = response.get("object", {})
 691 |         return response
 692 | 
 693 | 
 694 | @mcp.tool()
 695 | async def create_camera(
 696 |     name: Optional[str] = None,
 697 |     position: Optional[List[float]] = None,
 698 |     properties: Optional[Dict[str, Any]] = None,
 699 |     ctx: Context = None,
 700 | ) -> Dict[str, Any]:
 701 |     """
 702 |     Create a new camera in the scene.
 703 | 
 704 |     Args:
 705 |         name: Optional name for the new camera.
 706 |         position: Optional [x, y, z] position.
 707 |         properties: Optional dictionary of camera properties (e.g., {"focal_length": 50}).
 708 |     """
 709 |     # Generate a default name if none provided - use the name from the plugin side if needed
 710 |     requested_name = name
 711 | 
 712 |     async with c4d_connection_context() as connection:
 713 |         if not connection.connected:
 714 |             # Return error as dictionary for consistency
 715 |             return {"error": "❌ Not connected to Cinema 4D"}
 716 | 
 717 |         command = {"command": "create_camera"}
 718 |         if requested_name:
 719 |             command["name"] = (
 720 |                 requested_name  # Use the 'name' key expected by the handler
 721 |             )
 722 |         if position:
 723 |             command["position"] = position
 724 |         if properties:
 725 |             command["properties"] = properties
 726 | 
 727 |         response = send_to_c4d(connection, command)
 728 | 
 729 |         return response
 730 | 
 731 | 
 732 | @mcp.tool()
 733 | async def create_light(
 734 |     light_type: str, name: Optional[str] = None, ctx: Context = None
 735 | ) -> str:
 736 |     """
 737 |     Add a light to the scene.
 738 | 
 739 |     Args:
 740 |         light_type: Type of light (area, dome, spot)
 741 |         name: Optional name for the light
 742 |     """
 743 |     async with c4d_connection_context() as connection:
 744 |         if not connection.connected:
 745 |             return "❌ Not connected to Cinema 4D"
 746 | 
 747 |         command = {"command": "create_light", "type": light_type}
 748 | 
 749 |         if name:
 750 |             command["object_name"] = name
 751 | 
 752 |         response = send_to_c4d(connection, command)
 753 | 
 754 |         if "error" in response:
 755 |             return f"❌ Error: {response['error']}"
 756 | 
 757 |         object_info = response.get("object", {})
 758 |         return response
 759 | 
 760 | 
 761 | @mcp.tool()
 762 | async def apply_shader(
 763 |     shader_type: str,
 764 |     material_name: Optional[str] = None,
 765 |     object_name: Optional[str] = None,
 766 |     ctx: Context = None,
 767 | ) -> str:
 768 |     """
 769 |     Create and apply a specialized shader material.
 770 | 
 771 |     Args:
 772 |         shader_type: Type of shader (noise, gradient, fresnel, etc)
 773 |         material_name: Optional name of material to apply shader to
 774 |         object_name: Optional name of object to apply the material to
 775 |     """
 776 |     async with c4d_connection_context() as connection:
 777 |         if not connection.connected:
 778 |             return "❌ Not connected to Cinema 4D"
 779 | 
 780 |         command = {"command": "apply_shader", "shader_type": shader_type}
 781 | 
 782 |         if material_name:
 783 |             command["material_name"] = material_name
 784 | 
 785 |         if object_name:
 786 |             command["object_name"] = object_name
 787 | 
 788 |         response = send_to_c4d(connection, command)
 789 | 
 790 |         if "error" in response:
 791 |             return f"❌ Error: {response['error']}"
 792 | 
 793 |         shader_info = response.get("shader", {})
 794 |         material_name = shader_info.get("material", "New Material")
 795 |         applied_to = shader_info.get("applied_to", "None")
 796 |         applied_msg = f" and applied to '{applied_to}'" if applied_to != "None" else ""
 797 | 
 798 |         return response
 799 | 
 800 | 
 801 | @mcp.tool()
 802 | async def animate_camera(
 803 |     animation_type: str,
 804 |     camera_name: Optional[str] = None,
 805 |     positions: Optional[List[List[float]]] = None,
 806 |     frames: Optional[List[int]] = None,
 807 |     ctx: Context = None,
 808 | ) -> str:
 809 |     """
 810 |     Create a camera animation.
 811 | 
 812 |     Args:
 813 |         animation_type: Type of animation (wiggle, orbit, spline, linear)
 814 |         camera_name: Optional name of camera to animate
 815 |         positions: Optional list of [x,y,z] camera positions for keyframes
 816 |         frames: Optional list of frame numbers for keyframes
 817 |     """
 818 |     async with c4d_connection_context() as connection:
 819 |         if not connection.connected:
 820 |             return "❌ Not connected to Cinema 4D"
 821 | 
 822 |         # Create command with the animation type
 823 |         command = {"command": "animate_camera", "path_type": animation_type}
 824 | 
 825 |         # Add camera name if provided
 826 |         if camera_name:
 827 |             command["camera_name"] = camera_name
 828 | 
 829 |         # Handle positions and frames if provided
 830 |         if positions:
 831 |             command["positions"] = positions
 832 | 
 833 |             # Generate frames if not provided (starting at 0 with 15 frame intervals)
 834 |             if not frames:
 835 |                 frames = [i * 15 for i in range(len(positions))]
 836 | 
 837 |             command["frames"] = frames
 838 | 
 839 |         if animation_type == "orbit":
 840 |             # For orbit animations, we need to generate positions in a circle
 841 |             # if none are provided
 842 |             if not positions:
 843 |                 # Create a set of default positions for an orbit animation
 844 |                 radius = 200  # Default orbit radius
 845 |                 height = 100  # Default height
 846 |                 points = 12  # Number of points around the circle
 847 | 
 848 |                 orbit_positions = []
 849 |                 orbit_frames = []
 850 | 
 851 |                 # Create positions in a circle
 852 |                 for i in range(points):
 853 |                     angle = (i / points) * 2 * 3.14159  # Convert to radians
 854 |                     x = radius * math.cos(angle)
 855 |                     z = radius * math.sin(angle)
 856 |                     y = height
 857 |                     orbit_positions.append([x, y, z])
 858 |                     orbit_frames.append(i * 10)  # 10 frames between positions
 859 | 
 860 |                 command["positions"] = orbit_positions
 861 |                 command["frames"] = orbit_frames
 862 | 
 863 |         # Send the command to Cinema 4D
 864 |         response = send_to_c4d(connection, command)
 865 | 
 866 |         if "error" in response:
 867 |             return f"❌ Error: {response['error']}"
 868 | 
 869 |         # Get the camera animation info
 870 |         camera_info = response.get("camera_animation", {})
 871 | 
 872 |         # Build a response message
 873 |         frames_info = ""
 874 |         if "frame_range" in camera_info:
 875 |             frames_info = (
 876 |                 f"\n- **Frame Range**: {camera_info.get('frame_range', [0, 0])}"
 877 |             )
 878 | 
 879 |         keyframe_info = ""
 880 |         if "keyframe_count" in camera_info:
 881 |             keyframe_info = f"\n- **Keyframes**: {camera_info.get('keyframe_count', 0)}"
 882 | 
 883 |         return response
 884 | 
 885 | 
 886 | @mcp.tool()
 887 | async def execute_python_script(script: str, ctx: Context) -> str:
 888 |     """
 889 |     Execute a Python script in Cinema 4D.
 890 | 
 891 |     Args:
 892 |         script: Python code to execute in Cinema 4D
 893 |     """
 894 |     async with c4d_connection_context() as connection:
 895 |         if not connection.connected:
 896 |             return "❌ Not connected to Cinema 4D"
 897 | 
 898 |         # Send command to Cinema 4D
 899 |         response = send_to_c4d(
 900 |             connection, {"command": "execute_python", "script": script}
 901 |         )
 902 | 
 903 |         if "error" in response:
 904 |             return f"❌ Error: {response['error']}"
 905 | 
 906 |         result = response.get("result", "No output")
 907 |         return response
 908 | 
 909 | 
 910 | @mcp.tool()
 911 | async def group_objects(
 912 |     object_names: List[str], group_name: Optional[str] = None, ctx: Context = None
 913 | ) -> str:
 914 |     """
 915 |     Group multiple objects under a null object.
 916 | 
 917 |     Args:
 918 |         object_names: List of object names to group
 919 |         group_name: Optional name for the group
 920 |     """
 921 |     async with c4d_connection_context() as connection:
 922 |         if not connection.connected:
 923 |             return "❌ Not connected to Cinema 4D"
 924 | 
 925 |         # Prepare command
 926 |         command = {"command": "group_objects", "object_names": object_names}
 927 | 
 928 |         if group_name:
 929 |             command["group_name"] = group_name
 930 | 
 931 |         # Send command to Cinema 4D
 932 |         response = send_to_c4d(connection, command)
 933 | 
 934 |         if "error" in response:
 935 |             return f"❌ Error: {response['error']}"
 936 | 
 937 |         group_info = response.get("group", {})
 938 | 
 939 |         # Format object list for display
 940 |         objects_str = ", ".join(object_names)
 941 |         if len(objects_str) > 50:
 942 |             # Truncate if too long
 943 |             objects_str = objects_str[:47] + "..."
 944 | 
 945 |         return response
 946 | 
 947 | 
 948 | @mcp.tool()
 949 | async def render_preview(
 950 |     width: Optional[int] = None,
 951 |     height: Optional[int] = None,
 952 |     frame: Optional[int] = None,
 953 |     ctx: Context = None,
 954 | ) -> str:
 955 |     """
 956 |     Render the current view and return a base64-encoded preview image.
 957 | 
 958 |     Args:
 959 |         width: Optional preview width in pixels
 960 |         height: Optional preview height in pixels
 961 |         frame: Optional frame number to render
 962 |     """
 963 |     async with c4d_connection_context() as connection:
 964 |         if not connection.connected:
 965 |             return "❌ Not connected to Cinema 4D"
 966 | 
 967 |         # Prepare command
 968 |         command = {"command": "render_preview"}
 969 | 
 970 |         if width:
 971 |             command["width"] = width
 972 |         if height:
 973 |             command["height"] = height
 974 |         if frame is not None:
 975 |             command["frame"] = frame
 976 | 
 977 |         # Set longer timeout for rendering
 978 |         logger.info(f"Sending render_preview command with parameters: {command}")
 979 | 
 980 |         # Send command to Cinema 4D
 981 |         response = send_to_c4d(connection, command)
 982 | 
 983 |         if "error" in response:
 984 |             return f"❌ Error: {response['error']}"
 985 | 
 986 |         # Check if the response contains the base64 image data
 987 |         if "image_data" not in response:
 988 |             return "❌ Error: No image data returned from Cinema 4D"
 989 | 
 990 |         # Get image dimensions
 991 |         preview_width = response.get("width", width or "default")
 992 |         preview_height = response.get("height", height or "default")
 993 | 
 994 |         # Display the image using markdown
 995 |         image_data = response["image_data"]
 996 |         image_format = response.get("format", "png")
 997 | 
 998 |         # Note: The plugin handler handle_render_preview was already designed
 999 |         # to return the structure needed for image display if successful.
1000 |         return response  # Return the raw dictionary
1001 | 
1002 | 
1003 | @mcp.tool()
1004 | async def snapshot_scene(
1005 |     file_path: Optional[str] = None, include_assets: bool = False, ctx: Context = None
1006 | ) -> str:
1007 |     """
1008 |     Create a snapshot of the current scene state.
1009 | 
1010 |     Args:
1011 |         file_path: Optional path to save the snapshot
1012 |         include_assets: Whether to include external assets in the snapshot
1013 |     """
1014 |     async with c4d_connection_context() as connection:
1015 |         if not connection.connected:
1016 |             return "❌ Not connected to Cinema 4D"
1017 | 
1018 |         # Prepare command
1019 |         command = {"command": "snapshot_scene"}
1020 | 
1021 |         if file_path:
1022 |             command["file_path"] = file_path
1023 | 
1024 |         command["include_assets"] = include_assets
1025 | 
1026 |         # Send command to Cinema 4D
1027 |         response = send_to_c4d(connection, command)
1028 | 
1029 |         if "error" in response:
1030 |             return f"❌ Error: {response['error']}"
1031 | 
1032 |         snapshot_info = response.get("snapshot", {})
1033 | 
1034 |         # Extract information
1035 |         path = snapshot_info.get("path", file_path or "Default location")
1036 |         size = snapshot_info.get("size", "Unknown")
1037 |         timestamp = snapshot_info.get("timestamp", "Unknown")
1038 | 
1039 |         # Format assets information if available
1040 |         assets_info = ""
1041 |         if "assets" in snapshot_info:
1042 |             assets_count = len(snapshot_info["assets"])
1043 |             assets_info = f"\n- **Assets Included**: {assets_count}"
1044 | 
1045 |         return response
1046 | 
1047 | 
1048 | @mcp.resource("c4d://primitives")
1049 | def get_primitives_info() -> str:
1050 |     """Get information about available Cinema 4D primitives."""
1051 |     return """
1052 | # Cinema 4D Primitive Objects
1053 | 
1054 | ## Cube
1055 | - **Parameters**: size, segments
1056 | 
1057 | ## Sphere
1058 | - **Parameters**: radius, segments
1059 | 
1060 | ## Cylinder
1061 | - **Parameters**: radius, height, segments
1062 | 
1063 | ## Cone
1064 | - **Parameters**: radius, height, segments
1065 | 
1066 | ## Plane
1067 | - **Parameters**: width, height, segments
1068 | 
1069 | ## Torus
1070 | - **Parameters**: outer radius, inner radius, segments
1071 | 
1072 | ## Pyramid
1073 | - **Parameters**: width, height, depth
1074 | 
1075 | ## Platonic
1076 | - **Parameters**: radius, type (tetrahedron, hexahedron, octahedron, dodecahedron, icosahedron)
1077 | """
1078 | 
1079 | 
1080 | @mcp.resource("c4d://material_types")
1081 | def get_material_types() -> str:
1082 |     """Get information about available Cinema 4D material types and their properties."""
1083 |     return """
1084 | # Cinema 4D Material Types
1085 | 
1086 | ## Standard Material
1087 | - **Color**: Base diffuse color
1088 | - **Specular**: Highlight color and intensity
1089 | - **Reflection**: Surface reflectivity
1090 | - **Transparency**: Surface transparency
1091 | - **Bump**: Surface bumpiness or displacement
1092 | 
1093 | ## Physical Material
1094 | - **Base Color**: Main surface color
1095 | - **Specular**: Surface glossiness and reflectivity
1096 | - **Roughness**: Surface irregularity
1097 | - **Metallic**: Metal-like properties
1098 | - **Transparency**: Light transmission properties
1099 | - **Emission**: Self-illumination properties
1100 | - **Normal**: Surface detail without geometry
1101 | - **Displacement**: Surface geometry modification
1102 | """
1103 | 
1104 | 
1105 | @mcp.resource("c4d://status")
1106 | def get_connection_status() -> str:
1107 |     """Get the current connection status to Cinema 4D."""
1108 |     is_connected = check_c4d_connection(C4D_HOST, C4D_PORT)
1109 |     status = (
1110 |         "✅ Connected to Cinema 4D" if is_connected else "❌ Not connected to Cinema 4D"
1111 |     )
1112 | 
1113 |     return f"""
1114 | # Cinema 4D Connection Status
1115 | {status}
1116 | 
1117 | ## Connection Details
1118 | - **Host**: {C4D_HOST}
1119 | - **Port**: {C4D_PORT}
1120 | """
1121 | 
1122 | 
1123 | mcp_app = mcp
1124 | 
```