# 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 |
```