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