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

```