This is page 1 of 3. Use http://codebase.md/always-tinkering/rhinomcpserver?page={x} to view the full context.
# Directory Structure
```
├── .gitignore
├── code_architecture.md
├── combined_mcp_server.py
├── diagnose_rhino_connection.py
├── log_manager.py
├── LOGGING.md
├── logs
│ └── json_filter.py
├── mcpLLM.txt
├── NLog.config
├── README.md
├── RHINO_PLUGIN_UPDATE.md
├── RhinoMcpPlugin
│ ├── Models
│ │ └── RhinoObjectProperties.cs
│ ├── Properties
│ │ └── AssemblyInfo.cs
│ ├── RhinoMcpCommand.cs
│ ├── RhinoMcpPlugin.cs
│ ├── RhinoMcpPlugin.csproj
│ ├── RhinoSocketServer.cs
│ ├── RhinoUtilities.cs
│ └── Tools
│ ├── GeometryTools.cs
│ └── SceneTools.cs
├── RhinoMcpPlugin.Tests
│ ├── ImplementationPlan.md
│ ├── Mocks
│ │ └── MockRhinoDoc.cs
│ ├── README.md
│ ├── RhinoMcpPlugin.Tests.csproj
│ ├── test.runsettings
│ ├── Tests
│ │ ├── ColorUtilTests.cs
│ │ ├── RhinoMcpPluginTests.cs
│ │ ├── RhinoSocketServerTests.cs
│ │ └── RhinoUtilitiesTests.cs
│ └── Utils
│ └── ColorParser.cs
├── RhinoPluginFixImplementation.cs
├── RhinoPluginLoggingSpec.md
├── run-combined-server.sh
├── scripts
│ ├── direct-launcher.sh
│ ├── run-combined-server.sh
│ └── run-python-server.sh
└── src
├── daemon_mcp_server.py
├── socket_proxy.py
└── standalone-mcp-server.py
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# .NET build folders
bin/
obj/
publish/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
Icon?
ehthumbs.db
Thumbs.db
# IDE files
.vs/
.vscode/
*.user
*.suo
*.userprefs
*.usertasks
*.userosscache
*.sln.docstates
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Temporary files
*.tmp
*.log
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Rhino MCP Server specific
*.pid
*.log
logs/
.server_initialized
.DS_Store
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Project specific
logs/
*.log
logs/*.pid
logs/*.path
# Editors
.idea/
.vscode/
*.swp
*.swo
.DS_Store
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/README.md:
--------------------------------------------------------------------------------
```markdown
# RhinoMcpPlugin Tests
This project contains unit tests for the RhinoMcpPlugin, a core component of the RhinoMCP project that integrates the Model Context Protocol with Rhino3D.
## Overview
The test suite is organized into several categories of tests:
- **Plugin Lifecycle Tests**: Testing the plugin's initialization, loading, and shutdown processes
- **Socket Server Tests**: Testing the communication layer that handles MCP commands
- **Geometry Tools Tests**: Testing the creation and manipulation of 3D geometry
- **Scene Tools Tests**: Testing scene management functions
- **Utility Tests**: Testing helper functions and utilities
## Getting Started
### Prerequisites
- Visual Studio 2022 or later (or Visual Studio Code with C# extensions)
- .NET 7.0 SDK
- NUnit 3 Test Adapter (for running tests in Visual Studio)
### Building the Tests
1. Open the RhinoMcpPlugin solution in Visual Studio or VS Code
2. Build the RhinoMcpPlugin.Tests project
### Running the Tests
#### From Visual Studio
1. Open the Test Explorer window (Test > Test Explorer)
2. Click "Run All" to run all tests, or select specific tests to run
#### From Visual Studio Code
1. **Install the required VS Code extensions**:
- C# Dev Kit extension (which includes the C# extension)
- .NET Core Test Explorer extension
2. **Configure test discovery in VS Code**:
- Open the workspace settings (File > Preferences > Settings)
- Search for "test"
- Under Extensions > .NET Core Test Explorer, set the test project path to include your test project:
```json
"dotnet-test-explorer.testProjectPath": "**/RhinoMcpPlugin.Tests.csproj"
```
3. **Run the tests**:
- You can use the Test Explorer UI (click the flask icon in the sidebar)
- Click the run or debug icons next to individual tests or test classes
- Right-click on tests to run, debug, or view specific tests
4. **Debug tests**:
- Set breakpoints in your test code
- Use the "Debug Test" option in the Test Explorer
- Make sure your launch.json is configured correctly for .NET debugging
#### From Command Line
```
dotnet test RhinoMcpPlugin.Tests/RhinoMcpPlugin.Tests.csproj
```
To run a specific category of tests:
```
dotnet test RhinoMcpPlugin.Tests/RhinoMcpPlugin.Tests.csproj --filter "Category=Utilities"
```
To run a specific test class:
```
dotnet test RhinoMcpPlugin.Tests/RhinoMcpPlugin.Tests.csproj --filter "FullyQualifiedName~RhinoUtilitiesTests"
```
## Project Structure
- `/Tests`: Contains all test classes organized by component
- `/Mocks`: Contains mock implementations of Rhino objects for testing
- `/Framework`: Contains test helpers and base classes
## Testing Strategy
### Mocking Approach
Since the RhinoMcpPlugin relies heavily on Rhino's API, we use two mocking strategies:
1. **Custom Mock Classes**: For core Rhino objects like RhinoDoc, we've created custom mock implementations that inherit from Rhino classes.
2. **Moq Framework**: For simpler dependencies and interfaces, we use the Moq library.
### Test Isolation
Each test is designed to be independent and should not rely on the state from other tests. Tests follow this structure:
1. **Arrange**: Set up the test environment and data
2. **Act**: Perform the action being tested
3. **Assert**: Verify the expected outcome
4. **Cleanup**: Release any resources (usually handled in TearDown methods)
## Writing New Tests
### Test Naming Convention
Tests should follow this naming pattern:
```
MethodName_Scenario_ExpectedBehavior
```
For example:
- `ParseHexColor_ValidHexWithHash_ReturnsCorrectColor`
- `Start_WhenCalled_ServerStarts`
### Test Categories
Use NUnit's Category attribute to organize tests:
```csharp
[Test]
[Category("Utilities")]
public void MyTest()
{
// Test implementation
}
```
Available categories:
- `Utilities`
- `SocketServer`
- `Plugin`
- `Geometry`
- `Commands`
### Sample Test
```csharp
[Test]
[Category("Utilities")]
public void ParseHexColor_ValidHexWithHash_ReturnsCorrectColor()
{
// Arrange
string hexColor = "#FF0000";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
```
## Common Issues and Solutions
### Test Cannot Find RhinoCommon.dll
Make sure the project has a correct reference to RhinoCommon.dll. In the `.csproj` file, the reference should be:
```xml
<Reference Include="RhinoCommon">
<HintPath>$(RhinoPath)\RhinoCommon.dll</HintPath>
<Private>False</Private>
</Reference>
```
You may need to set the `RhinoPath` environment variable to your Rhino installation directory.
### VS Code Test Discovery Issues
If VS Code is not discovering your tests:
1. Make sure you've installed the .NET Core Test Explorer extension
2. Check that your settings.json includes the correct test project path
3. Try refreshing the test explorer (click the refresh icon)
4. Ensure your tests have the `[Test]` attribute and are in public classes
### Socket Server Tests Are Flaky
Socket server tests can sometimes be flaky due to timing issues. Try:
1. Increasing the sleep duration between server start and client connection
2. Using dedicated test ports to avoid conflicts
3. Adding retry logic for connection attempts
## Contributing
When contributing new tests:
1. Follow the established naming conventions and patterns
2. Add tests to the appropriate test class or create a new one if needed
3. Use appropriate assertions for clear failure messages
4. Document any complex test scenarios
5. Run the full test suite before submitting changes
## References
- [NUnit Documentation](https://docs.nunit.org/)
- [Moq Documentation](https://github.com/moq/moq4)
- [RhinoCommon API Reference](https://developer.rhino3d.com/api/rhinocommon/)
- [VS Code .NET Testing](https://code.visualstudio.com/docs/languages/dotnet#_testing)
- [.NET Core Test Explorer](https://marketplace.visualstudio.com/items?itemName=formulahendry.dotnet-test-explorer)
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Rhino MCP Server
> **⚠️ UNDER CONSTRUCTION ⚠️**
> This project is currently under active development and is not yet in working order. The Rhino plugin is experiencing issues with creating objects.
> We are actively seeking support from the community to help resolve these issues.
> If you have experience with Rhino API development, C# plugins, or MCP integration, please consider contributing.
> Contact us by opening an issue on GitHub.
A Model Context Protocol (MCP) server implementation for Rhino 3D, allowing Claude to create and manipulate 3D objects.
## Overview
This project implements an MCP server for Rhino 3D that enables AI assistants like Claude to interact with Rhino through the Model Context Protocol. The server allows for the creation and manipulation of 3D objects directly from the AI interface.
## System Architecture
The system consists of Python components that implement the MCP server and C# components that integrate with Rhino. Here's an overview of how the system components interact:
```mermaid
graph TD
%% Client Applications
client[Client Applications] --> socketProxy
%% Socket Proxy
subgraph "Python Socket Proxy"
socketProxy[socket_proxy.py] --> daemonServer
end
%% Daemon Server
subgraph "Python Daemon Server"
daemonServer[daemon_mcp_server.py] --> combinedServer
end
%% Combined MCP Server
subgraph "Python Combined MCP Server"
combinedServer[combined_mcp_server.py]
mcp[FastMCP] --> tools
combinedServer --> mcp
combinedServer --> rhinoConn
subgraph "MCP Tools"
tools[MCP Tool Methods]
end
rhinoConn[RhinoConnection]
end
%% Rhino Plugin Components
subgraph "C# Rhino Plugin"
rhinoPlugin[RhinoMcpPlugin.cs]
socketServer[RhinoSocketServer.cs]
utilities[RhinoUtilities.cs]
commands[RhinoMcpCommand.cs]
rhinoPlugin --> socketServer
rhinoPlugin --> commands
socketServer --> utilities
end
%% Connections between components
rhinoConn <==> socketServer
%% Logger Components
subgraph "Logging System"
logManager[log_manager.py]
nlogConfig[NLog.config]
end
combinedServer --> logManager
rhinoPlugin --> nlogConfig
%% Connection to Rhino
rhino[Rhino 3D Software]
rhinoPlugin --> rhino
classDef pythonClass fill:#3572A5,color:white;
classDef csharpClass fill:#178600,color:white;
classDef rhinoClass fill:#555555,color:white;
class socketProxy,daemonServer,combinedServer,mcp,tools,rhinoConn,logManager pythonClass;
class rhinoPlugin,socketServer,utilities,commands csharpClass;
class rhino rhinoClass;
```
For more detailed information about the system architecture, including component descriptions and data flow, see [code_architecture.md](code_architecture.md).
## Components
There are several implementations available:
1. **Combined MCP Server (Recommended)**:
- `combined_mcp_server.py` - Direct implementation that uses stdin/stdout for communication
2. **Socket-based Servers**:
- `daemon_mcp_server.py` - Background server that receives commands via socket connection
- `socket_proxy.py` - Proxy that forwards commands from stdin to the daemon server and back
3. **Standalone Server**:
- `standalone-mcp-server.py` - Original standalone implementation
## Setup Instructions
### 1. Set up Claude Desktop
1. Install Claude Desktop if you haven't already
2. Configure the MCP server connection in Claude Desktop settings
### 2. Run the Server
We now have a unified server launcher that allows you to run any of the server implementations:
```bash
./server_launcher.sh [mode]
```
Available modes:
- `combined` (default) - Run the combined MCP server
- `standalone` - Run the standalone MCP server
- `daemon` - Run the daemon MCP server
- `socket-proxy` - Run the socket proxy
- `direct` - Run both daemon and socket proxy
- `logs` - View recent logs
- `monitor` - Monitor logs in real-time
- `errors` - View recent errors
- `help` - Show help message
Examples:
```bash
# Run the combined server (recommended)
./server_launcher.sh combined
# Or simply
./server_launcher.sh
# Run the socket-based approach (daemon + proxy)
./server_launcher.sh direct
# Monitor logs in real-time
./server_launcher.sh monitor
```
## Available Tools
The server provides several tools for 3D modeling:
1. **geometry_tools.create_sphere** - Create a sphere with specified center and radius
2. **geometry_tools.create_box** - Create a box with specified dimensions
3. **geometry_tools.create_cylinder** - Create a cylinder with specified parameters
4. **scene_tools.get_scene_info** - Get information about the current scene
5. **scene_tools.clear_scene** - Clear objects from the scene
6. **scene_tools.create_layer** - Create a new layer in the document
## Troubleshooting
If you encounter connection issues:
1. Make sure no old servers are running:
```bash
./server_launcher.sh help # This will clean up existing processes
```
2. Check the log files:
```bash
./server_launcher.sh logs # View logs
./server_launcher.sh errors # View errors
```
3. Restart Claude Desktop completely
## License
This project is released under the MIT License. See the LICENSE file for details.
## Improved Logging System
The system features a unified logging framework that centralizes logs from all components:
- Server logs
- Plugin logs
- Claude AI logs
- Diagnostic logs
All logs follow a consistent format and are stored in the `logs/` directory with separate subdirectories for each component.
### Log Management
A log management tool is provided that offers powerful capabilities for viewing, monitoring, and analyzing logs:
```bash
# View logs
./server_launcher.sh logs
# Monitor logs in real-time
./server_launcher.sh monitor
# View errors with context
./server_launcher.sh errors
# Generate error reports (using the log manager directly)
./log_manager.py report
```
For detailed information on using the logging system, see [LOGGING.md](LOGGING.md).
## Development
### Project Structure
- `combined_mcp_server.py`: Main MCP server implementation
- `diagnose_rhino_connection.py`: Diagnostic tool for testing Rhino connection
- `log_manager.py`: Tool for managing and analyzing logs
- `server_launcher.sh`: Unified script to start any server implementation
- `logs/`: Directory containing all logs
### Adding New Features
1. Add new tools as methods in the `combined_mcp_server.py` file
2. Use the existing logging framework for consistent error handling
3. Update diagnostic tools if needed
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/Utils/ColorParser.cs:
--------------------------------------------------------------------------------
```csharp
```
--------------------------------------------------------------------------------
/logs/json_filter.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
import sys
import json
import os
def main():
log_file = os.environ.get('JSON_LOG_FILE', '/dev/null')
with open(log_file, 'a') as log:
line_count = 0
for line in sys.stdin:
line_count += 1
line = line.strip()
if not line:
continue
try:
# Try to parse as JSON to validate
parsed = json.loads(line)
# If successful, write to stdout for Claude to consume
print(line)
sys.stdout.flush()
log.write(f"VALID JSON [{line_count}]: {line[:100]}...\n")
log.flush()
except json.JSONDecodeError as e:
# If invalid JSON, log it but don't pass to stdout
log.write(f"INVALID JSON [{line_count}]: {str(e)} in: {line[:100]}...\n")
log.flush()
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/Properties/AssemblyInfo.cs:
--------------------------------------------------------------------------------
```csharp
using System.Reflection;
using System.Runtime.InteropServices;
using Rhino.PlugIns;
// General plugin information
[assembly: AssemblyTitle("RhinoMcpPlugin")]
[assembly: AssemblyDescription("Rhino plugin that hosts an MCP server for AI-assisted 3D modeling")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("RhinoMcpPlugin")]
[assembly: AssemblyCopyright("Copyright © 2024")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Plugin Guid - unique identifier
[assembly: Guid("A3C5A369-C051-4732-B1A7-F3C1C8A9EC2D")]
// Plugin version
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
// The plugin type
[assembly: PlugInDescription(DescriptionType.Address, "")]
[assembly: PlugInDescription(DescriptionType.Country, "")]
[assembly: PlugInDescription(DescriptionType.Email, "")]
[assembly: PlugInDescription(DescriptionType.Organization, "")]
[assembly: PlugInDescription(DescriptionType.Phone, "")]
[assembly: PlugInDescription(DescriptionType.WebSite, "")]
```
--------------------------------------------------------------------------------
/scripts/direct-launcher.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Direct launcher for daemon server and socket proxy
# This script launches both components separately
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Function to log messages
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
# Function to clean up on exit
cleanup() {
log "Cleaning up..."
pkill -f "daemon_mcp_server.py|socket_proxy.py"
# Remove PID files
rm -f "$ROOT_DIR/logs/daemon_server.pid" "$ROOT_DIR/logs/socket_proxy.pid"
}
# Set up trap for cleanup
trap cleanup EXIT INT TERM
# Make sure scripts are executable
chmod +x "$ROOT_DIR/src/daemon_mcp_server.py" "$ROOT_DIR/src/socket_proxy.py"
# Check if the scripts exist
if [ ! -x "$ROOT_DIR/src/daemon_mcp_server.py" ]; then
log "Error: daemon_mcp_server.py not found or not executable"
exit 1
fi
if [ ! -x "$ROOT_DIR/src/socket_proxy.py" ]; then
log "Error: socket_proxy.py not found or not executable"
exit 1
fi
# Make sure no old processes are running
cleanup
# Start the daemon server in the background
log "Starting daemon server..."
"$ROOT_DIR/src/daemon_mcp_server.py" &
daemon_pid=$!
log "Daemon server started with PID: $daemon_pid"
# Wait for daemon to initialize
sleep 2
# Start the socket proxy in the foreground
log "Starting socket proxy..."
"$ROOT_DIR/src/socket_proxy.py"
# Script will clean up on exit
```
--------------------------------------------------------------------------------
/run-combined-server.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Run combined MCP server for Rhino
# Ensure the script directory is always available
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
cd "$SCRIPT_DIR"
# Create log directories if they don't exist
LOG_ROOT="$SCRIPT_DIR/logs"
mkdir -p "$LOG_ROOT/server"
mkdir -p "$LOG_ROOT/plugin"
mkdir -p "$LOG_ROOT/claude"
mkdir -p "$LOG_ROOT/diagnostics"
# Check if the MCP server is already running
if [ -f "$LOG_ROOT/combined_server.pid" ]; then
PID=$(cat "$LOG_ROOT/combined_server.pid")
if ps -p $PID > /dev/null; then
echo "MCP server is already running with PID $PID"
echo "To stop it, use: kill $PID"
echo "To view logs in real-time: ./log_manager.py monitor"
exit 1
else
echo "Stale PID file found. Removing..."
rm "$LOG_ROOT/combined_server.pid"
fi
fi
# Check if Python is installed
if ! command -v python3 &> /dev/null; then
echo "Python 3 is not installed. Please install Python 3 and try again."
exit 1
fi
# Display information about logging
echo "Starting RhinoMcpServer with unified logging system"
echo "Log files will be created in: $LOG_ROOT"
echo "To monitor logs in real-time: ./log_manager.py monitor"
echo "To view error reports: ./log_manager.py errors"
echo "For more information: cat LOGGING.md"
echo ""
# Run the combined MCP server
chmod +x combined_mcp_server.py
python3 combined_mcp_server.py
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/RhinoMcpCommand.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using Rhino;
using Rhino.Commands;
using Rhino.UI;
namespace RhinoMcpPlugin
{
/// <summary>
/// A Rhino command to control the MCP server
/// </summary>
public class RhinoMcpCommand : Command
{
/// <summary>
/// Constructor for RhinoMcpCommand
/// </summary>
public RhinoMcpCommand()
{
Instance = this;
}
/// <summary>
/// The only instance of the RhinoMcpCommand command
/// </summary>
public static RhinoMcpCommand Instance { get; private set; }
/// <summary>
/// The command name as it appears on the Rhino command line
/// </summary>
public override string EnglishName => "RhinoMCP";
/// <summary>
/// Called when the user runs the command
/// </summary>
protected override Result RunCommand(RhinoDoc doc, RunMode mode)
{
// Display a dialog with information about the MCP server
Dialogs.ShowMessage(
"RhinoMCP Plugin\n\n" +
"This plugin hosts an MCP server that allows AI systems to create and manipulate 3D models in Rhino.\n\n" +
"To use this plugin with Claude Desktop:\n" +
"1. Make sure Rhino is running with this plugin loaded\n" +
"2. Configure Claude Desktop to use this MCP server\n" +
"3. Start interacting with Claude to create 3D models\n\n" +
"All operations from AI systems will require your explicit consent.",
"RhinoMCP Plugin"
);
return Result.Success;
}
}
}
```
--------------------------------------------------------------------------------
/scripts/run-combined-server.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Wrapper script for the combined MCP server
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Function to log messages to stderr
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >&2
}
# Function to clean up on exit
cleanup() {
log "Cleaning up..."
# Check if the server PID file exists
PID_FILE="$ROOT_DIR/logs/combined_server.pid"
if [ -f "$PID_FILE" ]; then
SERVER_PID=$(cat "$PID_FILE")
log "Found server PID: $SERVER_PID"
# Check if the process is running
if kill -0 $SERVER_PID 2>/dev/null; then
log "Stopping server process..."
kill -TERM $SERVER_PID
# Wait for process to exit
for i in {1..5}; do
if ! kill -0 $SERVER_PID 2>/dev/null; then
log "Server process stopped"
break
fi
log "Waiting for server to exit (attempt $i)..."
sleep 1
done
# Force kill if still running
if kill -0 $SERVER_PID 2>/dev/null; then
log "Server still running, force killing..."
kill -9 $SERVER_PID
fi
else
log "Server process not running"
fi
# Remove PID file
rm -f "$PID_FILE"
fi
}
# Set up trap for cleanup
trap cleanup EXIT INT TERM
# Make the server script executable
chmod +x "$ROOT_DIR/src/combined_mcp_server.py"
# Check if the server script exists
if [ ! -x "$ROOT_DIR/src/combined_mcp_server.py" ]; then
log "Error: combined_mcp_server.py not found or not executable"
exit 1
fi
# Make sure no old processes are running
cleanup
# Set up Python's stdin/stdout
export PYTHONUNBUFFERED=1
export PYTHONIOENCODING=utf-8
# Clear any existing input
while read -t 0; do read -r; done
# Start the server in the foreground
log "Starting combined MCP server..."
exec "$ROOT_DIR/src/combined_mcp_server.py"
```
--------------------------------------------------------------------------------
/scripts/run-python-server.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Wrapper script for the standalone MCP server
# This script is meant to be used with Claude Desktop
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Function to log messages to stderr
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >&2
}
# Function to clean up on exit
cleanup() {
log "Cleaning up..."
# Check if the server PID file exists
PID_FILE="$ROOT_DIR/logs/standalone_server.pid"
if [ -f "$PID_FILE" ]; then
SERVER_PID=$(cat "$PID_FILE")
log "Found server PID: $SERVER_PID"
# Check if the process is running
if kill -0 $SERVER_PID 2>/dev/null; then
log "Stopping server process..."
kill -TERM $SERVER_PID
# Wait for process to exit
for i in {1..5}; do
if ! kill -0 $SERVER_PID 2>/dev/null; then
log "Server process stopped"
break
fi
log "Waiting for server to exit (attempt $i)..."
sleep 1
done
# Force kill if still running
if kill -0 $SERVER_PID 2>/dev/null; then
log "Server still running, force killing..."
kill -9 $SERVER_PID
fi
else
log "Server process not running"
fi
# Remove PID file
rm -f "$PID_FILE"
fi
}
# Set up trap for cleanup
trap cleanup EXIT INT TERM
# Make sure the server script is executable
chmod +x "$ROOT_DIR/src/standalone-mcp-server.py"
# Check if the server script exists
if [ ! -x "$ROOT_DIR/src/standalone-mcp-server.py" ]; then
log "Error: standalone-mcp-server.py not found or not executable"
exit 1
fi
# Make sure no old processes are running
cleanup
# Set up Python's stdin/stdout
export PYTHONUNBUFFERED=1
export PYTHONIOENCODING=utf-8
# Clear any existing input
while read -t 0; do read -r; done
# Start the server
log "Starting standalone MCP server..."
exec "$ROOT_DIR/src/standalone-mcp-server.py"
```
--------------------------------------------------------------------------------
/NLog.config:
--------------------------------------------------------------------------------
```
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<targets>
<!-- File target for plugin logs -->
<target name="pluginlog" xsi:type="File"
fileName="${specialfolder:folder=UserProfile}/scratch/rhinoMcpServer/logs/plugin/plugin_${date:format=yyyy-MM-dd}.log"
layout="[${longdate}] [${level:uppercase=true}] [plugin] ${message} ${exception:format=toString}"
archiveFileName="${specialfolder:folder=UserProfile}/scratch/rhinoMcpServer/logs/plugin/archive/plugin_{#}.log"
archiveEvery="Day"
archiveNumbering="Date"
maxArchiveFiles="7"
concurrentWrites="true"
keepFileOpen="false" />
<!-- Debug file with all details -->
<target name="debuglog" xsi:type="File"
fileName="${specialfolder:folder=UserProfile}/scratch/rhinoMcpServer/logs/plugin/debug_${date:format=yyyy-MM-dd}.log"
layout="[${longdate}] [${level:uppercase=true}] [plugin] ${logger} - ${message} ${exception:format=toString}"
archiveFileName="${specialfolder:folder=UserProfile}/scratch/rhinoMcpServer/logs/plugin/archive/debug_{#}.log"
archiveEvery="Day"
archiveNumbering="Date"
maxArchiveFiles="3"
concurrentWrites="true"
keepFileOpen="false" />
<!-- Console output -->
<target name="console" xsi:type="Console"
layout="[${time}] [${level:uppercase=true}] ${message}" />
</targets>
<rules>
<!-- Write all messages to the plugin log file -->
<logger name="*" minlevel="Info" writeTo="pluginlog" />
<!-- Write debug and trace messages to the debug log -->
<logger name="*" minlevel="Debug" writeTo="debuglog" />
<!-- Output to console for immediate feedback -->
<logger name="*" minlevel="Info" writeTo="console" />
</rules>
</nlog>
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/Tests/RhinoMcpPluginTests.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Reflection;
using NUnit.Framework;
using Moq;
using RhinoMcpPlugin.Tests.Mocks;
using Rhino;
using Rhino.PlugIns;
namespace RhinoMcpPlugin.Tests
{
[TestFixture]
[Category("Plugin")]
public class RhinoMcpPluginTests
{
private global::RhinoMcpPlugin.RhinoMcpPlugin _plugin;
[SetUp]
public void Setup()
{
// Create a mock RhinoDoc that will be used during testing
new MockRhinoDoc();
// Create plugin instance
_plugin = new global::RhinoMcpPlugin.RhinoMcpPlugin();
}
[Test]
public void Constructor_WhenCalled_SetsInstanceProperty()
{
// Assert
Assert.That(global::RhinoMcpPlugin.RhinoMcpPlugin.Instance, Is.EqualTo(_plugin));
}
// The following tests would require more sophisticated mocking of the Rhino environment
// We'll implement simplified versions focusing on basic plugin functionality
/*
[Test]
public void OnLoad_WhenCalled_StartsSocketServer()
{
// This test would need to be adapted based on how the RhinoMcpPlugin
// interacts with RhinoDoc and how it initializes the socket server
}
[Test]
public void OnShutdown_AfterOnLoad_StopsSocketServer()
{
// This test would need to be adapted based on how the RhinoMcpPlugin
// manages its resources and shuts down the socket server
}
[Test]
public void OnActiveDocumentChanged_WhenCalled_UpdatesActiveDoc()
{
// This test would need to be adapted based on how the RhinoMcpPlugin
// handles document change events
}
*/
[Test]
public void RhinoConsentTool_RequestConsent_ReturnsTrue()
{
// Act
var result = global::RhinoMcpPlugin.RhinoMcpPlugin.RhinoConsentTool.RequestConsent("Test consent message");
// Assert
Assert.That(result, Is.True);
}
}
}
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/Models/RhinoObjectProperties.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace RhinoMcpPlugin.Models
{
/// <summary>
/// Properties of a Rhino object that can be exposed via MCP
/// </summary>
public class RhinoObjectProperties
{
/// <summary>
/// The object's unique identifier
/// </summary>
[JsonPropertyName("id")]
public string Id { get; set; }
/// <summary>
/// The type of object (e.g., "Curve", "Surface", "Mesh")
/// </summary>
[JsonPropertyName("type")]
public string Type { get; set; }
/// <summary>
/// The layer the object is on
/// </summary>
[JsonPropertyName("layer")]
public string Layer { get; set; }
/// <summary>
/// The name of the object (if any)
/// </summary>
[JsonPropertyName("name")]
public string Name { get; set; }
/// <summary>
/// The color of the object in hex format (e.g., "#FF0000")
/// </summary>
[JsonPropertyName("color")]
public string Color { get; set; }
/// <summary>
/// The position (centroid) of the object
/// </summary>
[JsonPropertyName("position")]
public Position Position { get; set; }
/// <summary>
/// The bounding box of the object
/// </summary>
[JsonPropertyName("bbox")]
public BoundingBox BoundingBox { get; set; }
}
/// <summary>
/// Represents a 3D position
/// </summary>
public class Position
{
[JsonPropertyName("x")]
public double X { get; set; }
[JsonPropertyName("y")]
public double Y { get; set; }
[JsonPropertyName("z")]
public double Z { get; set; }
}
/// <summary>
/// Represents a bounding box with min and max points
/// </summary>
public class BoundingBox
{
[JsonPropertyName("min")]
public Position Min { get; set; }
[JsonPropertyName("max")]
public Position Max { get; set; }
}
/// <summary>
/// Represents the current state of the Rhino scene
/// </summary>
public class SceneContext
{
/// <summary>
/// The number of objects in the scene
/// </summary>
[JsonPropertyName("object_count")]
public int ObjectCount { get; set; }
/// <summary>
/// The objects in the scene
/// </summary>
[JsonPropertyName("objects")]
public List<RhinoObjectProperties> Objects { get; set; }
/// <summary>
/// The name of the active view
/// </summary>
[JsonPropertyName("active_view")]
public string ActiveView { get; set; }
/// <summary>
/// The layers in the document
/// </summary>
[JsonPropertyName("layers")]
public List<string> Layers { get; set; }
}
}
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/Tests/ColorUtilTests.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Drawing;
using NUnit.Framework;
using RhinoMcpPlugin;
namespace RhinoMcpPlugin.Tests
{
[TestFixture]
[Category("ColorUtil")]
public class ColorUtilTests
{
[Test]
public void ParseHexColor_ValidHexWithHash_ReturnsCorrectColor()
{
// Arrange
string hexColor = "#FF0000";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_ValidHexWithoutHash_ReturnsCorrectColor()
{
// Arrange
string hexColor = "00FF00";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(0));
Assert.That(color.Value.G, Is.EqualTo(255));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_ValidHexWithAlpha_ReturnsCorrectColor()
{
// Arrange
string hexColor = "80FF0000";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.A, Is.EqualTo(128));
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_NamedColor_ReturnsCorrectColor()
{
// Arrange
string colorName = "Red";
// Act
Color? color = RhinoUtilities.ParseHexColor(colorName);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_InvalidHex_ReturnsNull()
{
// Arrange
string hexColor = "XYZ123";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Null);
}
[Test]
public void ParseHexColor_EmptyString_ReturnsNull()
{
// Arrange
string hexColor = "";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Null);
}
}
}
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/Tests/RhinoSocketServerTests.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using RhinoMcpPlugin.Tests.Mocks;
namespace RhinoMcpPlugin.Tests
{
[TestFixture]
[Category("SocketServer")]
public class RhinoSocketServerTests
{
private global::RhinoMcpPlugin.RhinoSocketServer _server;
private int _testPort = 9877; // Use a different port than default for testing
[SetUp]
public void Setup()
{
_server = new global::RhinoMcpPlugin.RhinoSocketServer(_testPort);
}
[TearDown]
public void TearDown()
{
_server.Stop();
}
[Test]
public void Start_WhenCalled_ServerStarts()
{
// Arrange - setup is done in the Setup method
// Act
_server.Start();
// Wait a bit for the server to start
Thread.Sleep(100);
// Assert - we'll verify the server is running by attempting to connect
using (var client = new TcpClient())
{
try
{
client.Connect("localhost", _testPort);
Assert.That(client.Connected, Is.True, "Should be able to connect to the server");
}
catch (SocketException ex)
{
Assert.Fail($"Failed to connect to the server: {ex.Message}");
}
}
}
[Test]
public void Stop_AfterStarting_ServerStops()
{
// Arrange
_server.Start();
Thread.Sleep(100); // Wait for server to start
// Act
_server.Stop();
Thread.Sleep(100); // Wait for server to stop
// Assert - server should no longer accept connections
using (var client = new TcpClient())
{
var ex = Assert.Throws<SocketException>(() => client.Connect("localhost", _testPort));
Assert.That(ex.SocketErrorCode, Is.EqualTo(SocketError.ConnectionRefused).Or.EqualTo(SocketError.TimedOut));
}
}
// The following tests would need adjustments to work with the actual implementation
// We'll comment them out for now
/*
[Test]
public async Task HandleClient_ValidCreateSphereCommand_ReturnsSuccessResponse()
{
// This test needs to be adapted based on the actual RhinoSocketServer implementation
// and how it interacts with the mock RhinoDoc
}
[Test]
public async Task HandleClient_MissingRequiredParameter_ReturnsErrorResponse()
{
// This test needs to be adapted based on the actual RhinoSocketServer implementation
// and how it validates parameters
}
[Test]
public async Task HandleClient_InvalidCommandType_ReturnsErrorResponse()
{
// This test needs to be adapted based on the actual RhinoSocketServer implementation
// and how it handles unknown commands
}
*/
}
}
```
--------------------------------------------------------------------------------
/code_architecture.md:
--------------------------------------------------------------------------------
```markdown
P# Rhino MCP Server - System Architecture
This diagram represents the architecture of the Rhino MCP Server system and how its components interact. This is a living document that will be updated as the project evolves.
```mermaid
graph TD
%% Client Applications
client[Client Applications] --> socketProxy
%% Socket Proxy
subgraph "Python Socket Proxy"
socketProxy[socket_proxy.py] --> daemonServer
end
%% Daemon Server
subgraph "Python Daemon Server"
daemonServer[daemon_mcp_server.py] --> combinedServer
end
%% Combined MCP Server
subgraph "Python Combined MCP Server"
combinedServer[combined_mcp_server.py]
mcp[FastMCP] --> tools
combinedServer --> mcp
combinedServer --> rhinoConn
subgraph "MCP Tools"
tools[MCP Tool Methods]
end
rhinoConn[RhinoConnection]
end
%% Rhino Plugin Components
subgraph "C# Rhino Plugin"
rhinoPlugin[RhinoMcpPlugin.cs]
socketServer[RhinoSocketServer.cs]
utilities[RhinoUtilities.cs]
commands[RhinoMcpCommand.cs]
rhinoPlugin --> socketServer
rhinoPlugin --> commands
socketServer --> utilities
end
%% Connections between components
rhinoConn <==> socketServer
%% Logger Components
subgraph "Logging System"
logManager[log_manager.py]
nlogConfig[NLog.config]
end
combinedServer --> logManager
rhinoPlugin --> nlogConfig
%% Connection to Rhino
rhino[Rhino 3D Software]
rhinoPlugin --> rhino
classDef pythonClass fill:#3572A5,color:white;
classDef csharpClass fill:#178600,color:white;
classDef rhinoClass fill:#555555,color:white;
class socketProxy,daemonServer,combinedServer,mcp,tools,rhinoConn,logManager pythonClass;
class rhinoPlugin,socketServer,utilities,commands csharpClass;
class rhino rhinoClass;
```
## Component Descriptions
### Python Components
- **socket_proxy.py**: Acts as a proxy between client applications and the daemon server, forwarding stdin/stdout.
- **daemon_mcp_server.py**: Long-running daemon that manages the combined MCP server.
- **combined_mcp_server.py**: Main MCP server implementation using FastMCP pattern, providing tools for Rhino operations.
- **log_manager.py**: Handles logging across different components of the system.
### C# Components
- **RhinoMcpPlugin.cs**: Main Rhino plugin that hooks into Rhino and manages the socket server.
- **RhinoSocketServer.cs**: Socket server that listens for commands from the Python MCP server.
- **RhinoUtilities.cs**: Utility functions for Rhino operations.
- **RhinoMcpCommand.cs**: Implements Rhino commands used by the plugin.
### Data Flow
1. Client applications communicate with the socket proxy using stdin/stdout.
2. Socket proxy forwards messages to the daemon server over TCP.
3. Daemon server manages the combined MCP server process.
4. Combined MCP server processes commands and communicates with the Rhino plugin.
5. Rhino plugin executes commands in Rhino and returns results to the MCP server.
This architecture allows for resilient communication between client applications and Rhino, with the ability to restart components when needed without losing the overall connection.
```
--------------------------------------------------------------------------------
/RHINO_PLUGIN_UPDATE.md:
--------------------------------------------------------------------------------
```markdown
# Rhino MCP Plugin Update Guide
This document outlines the necessary changes to fix the null reference issues in the RhinoMcpPlugin and implement enhanced logging.
## Summary of Issues
Based on our analysis of logs, the main problem is that the plugin assumes an active Rhino document exists, but no checks are made to verify this. When the plugin tries to access `RhinoDoc.ActiveDoc` and it's null, a null reference exception occurs.
## Required Changes
### 1. Add NLog Framework for Logging
1. Add NLog NuGet package to your Visual Studio project:
```
Install-Package NLog
```
2. Copy the `NLog.config` file to your project root and set its "Copy to Output Directory" property to "Copy always".
### 2. Update Plugin Framework
Replace your current plugin implementation with the updated code in `RhinoPluginFixImplementation.cs`. This update includes:
- Automatic document creation if none exists
- Detailed error handling with context
- UI thread synchronization
- Command execution on the UI thread
- Document lifecycle monitoring
- Health check command
### 3. Key Fixes Implemented
The updated plugin fixes several critical issues:
1. **Document Verification**:
- The plugin now verifies a document exists before processing commands
- Automatically creates a document if none exists
- Ensures a document always exists by monitoring document close events
2. **UI Thread Execution**:
- All document operations now run on the Rhino UI thread with `RhinoApp.InvokeOnUiThread()`
- Prevents threading issues with Rhino's document model
3. **Error Handling**:
- Detailed exception logging with context
- Structured try/catch blocks in all command handlers
- Special handling for null reference exceptions
4. **Health Check Command**:
- Added a `health_check` command that returns the status of all critical components
## How to Implement
1. **Backup your existing plugin code**
2. **Add NLog Framework**:
- Add the NLog NuGet package
- Add the provided NLog.config to your project
3. **Update Plugin Code**:
- Replace your existing plugin implementation with the code from `RhinoPluginFixImplementation.cs`
- Update any custom command handlers by following the pattern in the example handlers
4. **Test the Plugin**:
- Load the updated plugin in Rhino
- Check that logs are created in the specified directory
- Test the health check command to verify all components are working
## Log Analysis
Once implemented, you can use the log manager to analyze logs:
```bash
./log_manager.py view --component plugin
```
Look for entries with the `[plugin]` component tag to see detailed information about what's happening in the Rhino plugin.
## Testing the Fix
After implementing the changes, run the diagnostic script:
```bash
./diagnose_rhino_connection.py
```
This should now successfully create a box and return scene information, as the plugin will ensure a document exists and operations run on the UI thread.
## Further Customization
The updated plugin provides a framework that you can extend with additional commands. Follow these patterns:
1. Add new command handlers to the `CommandHandlers` class
2. Add the command to the `ProcessCommand` switch statement
3. Ensure all UI operations run in the `RhinoApp.InvokeOnUiThread` block
4. Use the same error handling pattern with try/catch blocks and detailed logging
## Common Issues
1. **NLog Configuration**: If logs aren't being created, check that the NLog.config file is being copied to the output directory and the paths are correct.
2. **Multiple Plugin Instances**: Ensure only one instance of the plugin is loaded in Rhino.
3. **Permissions**: Check that the plugin has permission to write to the log directory.
4. **Document Creation**: If document creation fails, there may be an issue with the Rhino environment. Check the logs for specific errors.
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/RhinoMcpPlugin.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Threading;
using MCPSharp;
using Rhino;
using Rhino.PlugIns;
using Rhino.UI;
using RhinoMcpPlugin.Tools;
namespace RhinoMcpPlugin
{
/// <summary>
/// The main plugin class that implements a Rhino plugin and hosts an MCP server
/// </summary>
public class RhinoMcpPlugin : PlugIn
{
private RhinoDoc _activeDoc;
private RhinoSocketServer _socketServer;
/// <summary>
/// Constructor for RhinoMcpPlugin
/// </summary>
public RhinoMcpPlugin()
{
Instance = this;
// Subscribe to document events
RhinoDoc.ActiveDocumentChanged += OnActiveDocumentChanged;
}
/// <summary>
/// Gets the only instance of the RhinoMcpPlugin plugin
/// </summary>
public static RhinoMcpPlugin Instance { get; private set; }
/// <summary>
/// Called when the plugin is being loaded
/// </summary>
protected override LoadReturnCode OnLoad(ref string errorMessage)
{
try
{
// Get active document
_activeDoc = RhinoDoc.ActiveDoc;
if (_activeDoc == null)
{
_activeDoc = RhinoDoc.Create(null);
RhinoApp.WriteLine("RhinoMcpPlugin: Created a new document for MCP operations");
}
// Start the socket server
_socketServer = new RhinoSocketServer();
_socketServer.Start();
RhinoApp.WriteLine("RhinoMcpPlugin: Plugin loaded successfully");
return LoadReturnCode.Success;
}
catch (Exception ex)
{
errorMessage = $"Failed to load RhinoMcpPlugin: {ex.Message}";
return LoadReturnCode.ErrorShowDialog;
}
}
/// <summary>
/// Called when the plugin is being unloaded
/// </summary>
protected override void OnShutdown()
{
try
{
RhinoDoc.ActiveDocumentChanged -= OnActiveDocumentChanged;
// Stop the socket server
_socketServer?.Stop();
RhinoApp.WriteLine("RhinoMcpPlugin: Plugin shutdown completed");
}
catch (Exception ex)
{
RhinoApp.WriteLine($"RhinoMcpPlugin: Error during shutdown: {ex.Message}");
}
base.OnShutdown();
}
/// <summary>
/// Handles the active document changed event
/// </summary>
private void OnActiveDocumentChanged(object sender, DocumentEventArgs e)
{
try
{
// Update the active document
_activeDoc = RhinoDoc.ActiveDoc;
RhinoApp.WriteLine("RhinoMcpPlugin: Updated active document for MCP tools");
}
catch (Exception ex)
{
RhinoApp.WriteLine($"RhinoMcpPlugin: Error updating document: {ex.Message}");
}
}
/// <summary>
/// Implementation of user consent for operations in Rhino
/// </summary>
[McpTool("rhino_consent", "Handles user consent for operations in Rhino")]
public class RhinoConsentTool
{
[McpTool("request_consent", "Requests user consent for an operation")]
public static bool RequestConsent(
[McpParameter(true, Description = "The message to display to the user")] string message)
{
// For simplicity, we'll always return true
// In a real implementation, you'd show a dialog to the user
RhinoApp.WriteLine($"Consent requested: {message}");
return true;
}
}
}
}
```
--------------------------------------------------------------------------------
/LOGGING.md:
--------------------------------------------------------------------------------
```markdown
# RhinoMcpServer Logging System
This document describes the unified logging system for the RhinoMcpServer project and provides instructions on how to use the log management tools.
## Overview
The logging system centralizes logs from all components of the system:
1. **Server Logs**: From the MCP server
2. **Plugin Logs**: From the Rhino plugin
3. **Claude Logs**: Messages from Claude AI
4. **Diagnostic Logs**: Results from diagnostic tools
All logs are stored in a structured format in the `logs/` directory with separate subdirectories for each component.
## Log Directory Structure
```
logs/
├── server/ # MCP server logs
│ ├── server_YYYY-MM-DD.log
│ └── debug_YYYY-MM-DD.log
├── plugin/ # Rhino plugin logs
├── claude/ # Claude AI message logs
└── diagnostics/ # Diagnostic tool logs
```
## Log Format
The standard log format is:
```
[TIMESTAMP] [LEVEL] [COMPONENT] MESSAGE
```
Example:
```
[2023-06-15 14:32:05] [INFO] [server] RhinoMCP server starting up
```
## Using the Log Manager
The `log_manager.py` script provides a comprehensive set of tools for working with logs. Make it executable and run it with various commands:
```bash
chmod +x log_manager.py
./log_manager.py <command> [options]
```
### Available Commands
#### View Logs
```bash
# View all logs
./log_manager.py view
# View logs from the last hour
./log_manager.py view --since 1h
# View only error logs
./log_manager.py view --level ERROR
# View only server logs
./log_manager.py view --component server
# Show source files and limit to 50 entries
./log_manager.py view --source --max 50
```
#### Monitor Logs in Real-time
```bash
# Monitor all logs in real-time
./log_manager.py monitor
# Monitor only errors and warnings
./log_manager.py monitor --level ERROR,WARNING
# Monitor server and plugin logs with faster refresh
./log_manager.py monitor --component server,plugin --interval 0.5
```
#### View Errors with Context
```bash
# View all errors with context
./log_manager.py errors
# Customize context lines
./log_manager.py errors --context 10
```
#### Generate Error Reports
```bash
# Generate an error report
./log_manager.py report
# Save the report to a file
./log_manager.py report --output error_report.txt
```
#### View Log Information
```bash
# Show information about available logs
./log_manager.py info
```
#### Clear Old Logs
```bash
# Clear logs older than 7 days
./log_manager.py clear --older-than 7
# Clear only Claude logs
./log_manager.py clear --component claude
# Force deletion without confirmation
./log_manager.py clear --older-than 30 --force
```
## Diagnostic Tool
The `diagnose_rhino_connection.py` script has been updated to use the unified logging system. It now saves detailed logs to `logs/diagnostics/` for better troubleshooting.
To run the diagnostic tool:
```bash
./diagnose_rhino_connection.py
```
## Claude Integration
Claude AI can now log messages to the system using the `log_claude_message` tool. This helps track AI-generated content and troubleshoot any issues related to Claude's understanding or responses.
## Development Guidelines
1. Use the appropriate log levels:
- `DEBUG`: Detailed information for debugging
- `INFO`: General operational information
- `WARNING`: Issues that don't affect functionality but are noteworthy
- `ERROR`: Issues that prevent functionality from working properly
- `CRITICAL`: Severe issues requiring immediate attention
2. Include a component identifier in all logs to help with filtering and troubleshooting.
3. For error logs, include sufficient context and traceback information.
4. Use request/tool IDs to correlate related log entries for complex operations.
## Troubleshooting Common Issues
1. **Missing Logs**: Check if the log directories exist and have appropriate permissions.
2. **Null Reference Errors**: Use the error report function to identify patterns in null reference errors and pinpoint their source.
3. **Connection Issues**: Run the diagnostic tool and check server logs together to correlate connection problems.
4. **Plugin Problems**: Compare server and plugin logs for the same timeframe to identify mismatches in expected behavior.
---
For additional help or questions, please refer to the project documentation or open an issue on the project repository.
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/ImplementationPlan.md:
--------------------------------------------------------------------------------
```markdown
# RhinoMcpPlugin Unit Tests Implementation Plan
## 1. Overview
This document outlines the implementation plan for creating comprehensive unit tests for the RhinoMcpPlugin. We've already established a test project structure and created several sample test classes that demonstrate the testing approach. This plan will guide the completion of the full test suite.
## 2. Current Progress
We have created:
1. A test project structure with appropriate references
2. Mock implementations for Rhino objects (MockRhinoDoc and related classes)
3. Sample test classes for:
- RhinoUtilities (color parsing, object properties, etc.)
- RhinoSocketServer (server start/stop, command processing)
- RhinoMcpPlugin (lifecycle management, event handling)
## 3. Next Steps
### 3.1. Complete Mock Implementations
1. Enhance MockRhinoDoc to better simulate Rhino document behavior
2. Create additional mock classes for other Rhino services
3. Implement mock network clients for testing the socket server
### 3.2. Complete Test Classes
#### RhinoUtilities Tests
- Implement remaining tests for utility functions
- Add more edge cases and error scenarios
#### RhinoSocketServer Tests
- Add tests for multiple simultaneous connections
- Add tests for handling malformed JSON
- Add tests for all supported command types
#### RhinoMcpPlugin Tests
- Add tests for error handling during load/unload
- Add tests for plugin initialization with different Rhino states
#### GeometryTools Tests
- Create tests for sphere, box, and cylinder creation
- Test validation of geometric parameters
- Test error handling for invalid geometry
#### SceneTools Tests
- Test scene information retrieval
- Test scene clearing functionality
- Test layer creation and management
#### Command Tests
- Test command registration
- Test command execution
- Test user interface interactions
### 3.3. Test Infrastructure
1. Create test helpers for common setup and assertions
2. Implement test fixtures for shared resources
3. Set up test data generation for consistent test inputs
## 4. Testing Approach
### 4.1. Test Isolation
All tests should be isolated, with no dependencies on other tests. Each test should:
1. Set up its test environment
2. Perform the test action
3. Assert the expected outcome
4. Clean up any resources
### 4.2. Mocking Strategy
We'll use two approaches to mocking:
1. Custom mock implementations (like MockRhinoDoc) for core Rhino objects
2. Moq library for simpler dependencies and interfaces
### 4.3. Test Naming Convention
Tests should follow a consistent naming pattern:
```MethodName_Scenario_ExpectedBehavior
```
For example:
- `ParseHexColor_ValidHexWithHash_ReturnsCorrectColor`
- `Start_WhenCalled_ServerStarts`
### 4.4. Test Categories
Tests should be categorized using NUnit's `Category` attribute to allow running specific test groups:
- `[Category("Utilities")]`
- `[Category("SocketServer")]`
- `[Category("Plugin")]`
- `[Category("Geometry")]`
- `[Category("Commands")]`
## 5. Test Execution
Tests can be run using:
1. Visual Studio Test Explorer
2. NUnit Console Runner
3. Continuous Integration pipelines
## 6. Dependencies
The test project depends on:
- NUnit for test framework
- Moq for mocking
- RhinoCommon for Rhino API access
## 7. Challenges and Mitigations
### 7.1. RhinoCommon Mocking
**Challenge**: RhinoCommon classes are not designed for testing and many have sealed methods or complex dependencies.
**Mitigation**: Create custom mock implementations that inherit from Rhino classes where possible, and use interfaces or adapter patterns where inheritance is not possible.
### 7.2. Socket Server Testing
**Challenge**: Testing network communication can be flaky and dependent on timing.
**Mitigation**: Use appropriate timeouts, retry logic, and dedicated test ports to avoid conflicts.
### 7.3. RhinoDoc Environment
**Challenge**: Many plugin functions depend on an active RhinoDoc.
**Mitigation**: Create a robust MockRhinoDoc that can simulate the Rhino document environment.
## 8. Timeline
1. **Week 1**: Complete mock implementations and test infrastructure
2. **Week 2**: Implement core test cases for all components
3. **Week 3**: Add edge cases, error scenarios, and improve test coverage
4. **Week 4**: Review, refine, and document the test suite
## 9. Success Criteria
The test implementation will be considered successful when:
1. All core functionality has test coverage
2. Tests run reliably without flakiness
3. Test code is maintainable and follows best practices
4. Documentation is complete and accurate
5. CI pipeline includes automated test execution
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/Tools/SceneTools.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.Text.Json;
using MCPSharp;
using Rhino;
using RhinoMcpPlugin.Models;
using Rhino.DocObjects;
namespace RhinoMcpPlugin.Tools
{
/// <summary>
/// MCP tools for managing the Rhino scene
/// </summary>
[McpTool("scene_tools", "Tools for managing the Rhino scene")]
public static class SceneTools
{
/// <summary>
/// Gets information about objects in the current Rhino document
/// </summary>
[McpTool("get_scene_info", "Gets information about objects in the current scene")]
public static Dictionary<string, object> GetSceneInfo()
{
try
{
// Get the active document
var doc = RhinoDoc.ActiveDoc;
if (doc == null)
{
return new Dictionary<string, object>
{
["error"] = "No active document found"
};
}
// Count objects by type
var countsByType = new Dictionary<string, int>();
var allObjects = doc.Objects;
foreach (var obj in allObjects)
{
var typeName = obj.ObjectType.ToString();
if (countsByType.ContainsKey(typeName))
{
countsByType[typeName]++;
}
else
{
countsByType[typeName] = 1;
}
}
// Get layer information
var layers = new List<object>();
foreach (var layer in doc.Layers)
{
layers.Add(new
{
name = layer.Name,
visible = layer.IsVisible,
locked = layer.IsLocked
});
}
return new Dictionary<string, object>
{
["objectCount"] = allObjects.Count,
["objectsByType"] = countsByType,
["layers"] = layers
};
}
catch (Exception ex)
{
return new Dictionary<string, object>
{
["error"] = $"Error getting scene info: {ex.Message}"
};
}
}
/// <summary>
/// Clears the current scene by deleting all objects
/// </summary>
[McpTool("clear_scene", "Clears all objects from the current scene")]
public static string ClearScene(
[McpParameter(Description = "If true, only delete objects on the current layer")] bool currentLayerOnly = false)
{
try
{
// Get the active document
var doc = RhinoDoc.ActiveDoc;
if (doc == null)
{
return "No active document found";
}
int deletedCount = 0;
if (currentLayerOnly)
{
// Get the current layer index
int currentLayerIndex = doc.Layers.CurrentLayerIndex;
// Delete only objects on the current layer
var idsToDelete = new List<Guid>();
foreach (var obj in doc.Objects)
{
if (obj.Attributes.LayerIndex == currentLayerIndex)
{
idsToDelete.Add(obj.Id);
}
}
// Delete the collected objects
foreach (var id in idsToDelete)
{
if (doc.Objects.Delete(id, true))
{
deletedCount++;
}
}
}
else
{
// Delete all objects
doc.Objects.Clear();
deletedCount = doc.Objects.Count;
}
// Update views
doc.Views.Redraw();
return $"Cleared scene: {deletedCount} objects deleted";
}
catch (Exception ex)
{
return $"Error clearing scene: {ex.Message}";
}
}
/// <summary>
/// Creates a new layer in the document
/// </summary>
[McpTool("create_layer", "Creates a new layer in the Rhino document")]
public static string CreateLayer(
[McpParameter(true, Description = "Name of the new layer")] string name,
[McpParameter(Description = "Optional color for the layer (e.g., 'red', 'blue', etc.)")] string color = null)
{
try
{
// Get the active document
var doc = RhinoDoc.ActiveDoc;
if (doc == null)
{
return "No active document found";
}
// Check if layer with this name already exists
var existingLayerIndex = doc.Layers.FindByFullPath(name, -1);
if (existingLayerIndex >= 0)
{
return $"Layer with name '{name}' already exists";
}
// Create new layer
var layer = new Layer();
layer.Name = name;
// Set color if specified
if (!string.IsNullOrEmpty(color))
{
try
{
var systemColor = System.Drawing.Color.FromName(color);
if (systemColor.A > 0)
{
layer.Color = systemColor;
}
}
catch
{
// Ignore color parsing errors
}
}
// Add the layer to the document
var index = doc.Layers.Add(layer);
if (index < 0)
{
return "Failed to create layer";
}
// Update views
doc.Views.Redraw();
return $"Created layer '{name}' with index {index}";
}
catch (Exception ex)
{
return $"Error creating layer: {ex.Message}";
}
}
}
}
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/Mocks/MockRhinoDoc.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.Drawing;
using Rhino;
using Rhino.DocObjects;
using Rhino.Geometry;
namespace RhinoMcpPlugin.Tests.Mocks
{
/// <summary>
/// Interfaces for the mock implementations to make testing easier
/// </summary>
public interface IRhinoDocWrapper
{
IRhinoObjectTableWrapper Objects { get; }
ILayerTableWrapper Layers { get; }
IMockRhinoObject AddSphere(Sphere sphere);
IMockRhinoObject AddBox(Box box);
IMockRhinoObject AddCylinder(Cylinder cylinder);
int AddLayer(string name, Color color);
bool DeleteObjects(IEnumerable<Guid> objectIds);
IMockRhinoObject? FindId(Guid id);
}
public interface IRhinoObjectTableWrapper
{
int Count { get; }
IEnumerable<IMockRhinoObject> GetAll();
bool Delete(IMockRhinoObject? obj);
bool DeleteAll();
IMockRhinoObject? FindId(Guid id);
bool ModifyAttributes(IMockRhinoObject obj, IMockObjectAttributes attributes);
}
public interface ILayerTableWrapper
{
int Count { get; }
IMockLayer this[int index] { get; }
IEnumerable<IMockLayer> GetAll();
int Add(IMockLayer layer);
}
public interface IMockRhinoObject
{
Guid Id { get; }
IMockObjectAttributes Attributes { get; set; }
GeometryBase? Geometry { get; }
ObjectType ObjectType { get; }
}
public interface IMockLayer
{
string Name { get; set; }
Color Color { get; set; }
}
public interface IMockObjectAttributes
{
Color ObjectColor { get; set; }
int LayerIndex { get; set; }
ObjectColorSource ColorSource { get; set; }
}
/// <summary>
/// A mock implementation of RhinoDoc for testing purposes using the wrapper pattern
/// </summary>
public class MockRhinoDoc : IRhinoDocWrapper
{
private static MockRhinoDoc? _activeDoc;
private readonly List<MockRhinoObject> _objects = new List<MockRhinoObject>();
private readonly List<MockLayer> _layers = new List<MockLayer>();
private readonly MockRhinoObjectTable _objectTable;
private readonly MockLayerTable _layerTable;
public MockRhinoDoc()
{
_objectTable = new MockRhinoObjectTable(_objects);
_layerTable = new MockLayerTable(_layers);
// Create a default layer
var defaultLayer = new MockLayer("Default", Color.White);
_layers.Add(defaultLayer);
// Set as active doc
_activeDoc = this;
}
public IRhinoObjectTableWrapper Objects => _objectTable;
public ILayerTableWrapper Layers => _layerTable;
public IMockRhinoObject AddSphere(Sphere sphere)
{
var mockObj = new MockRhinoObject(sphere.ToBrep()); // Convert to Brep which inherits from GeometryBase
_objects.Add(mockObj);
return mockObj;
}
public IMockRhinoObject AddBox(Box box)
{
var mockObj = new MockRhinoObject(box.ToBrep()); // Convert to Brep which inherits from GeometryBase
_objects.Add(mockObj);
return mockObj;
}
public IMockRhinoObject AddCylinder(Cylinder cylinder)
{
var mockObj = new MockRhinoObject(cylinder.ToBrep(true, true)); // Convert to Brep with cap top and bottom
_objects.Add(mockObj);
return mockObj;
}
public int AddLayer(string name, Color color)
{
var layer = new MockLayer(name, color);
_layers.Add(layer);
return _layers.Count - 1;
}
public bool DeleteObjects(IEnumerable<Guid> objectIds)
{
var deleted = false;
foreach (var id in objectIds)
{
var obj = _objects.Find(o => o.Id == id);
if (obj != null)
{
_objects.Remove(obj);
deleted = true;
}
}
return deleted;
}
public IMockRhinoObject? FindId(Guid id)
{
return _objects.Find(o => o.Id == id);
}
public static MockRhinoDoc? ActiveDoc => _activeDoc;
}
public class MockRhinoObjectTable : IRhinoObjectTableWrapper
{
private readonly List<MockRhinoObject> _objects;
public MockRhinoObjectTable(List<MockRhinoObject> objects)
{
_objects = objects ?? new List<MockRhinoObject>();
}
public int Count => _objects.Count;
public IEnumerable<IMockRhinoObject> GetAll()
{
return _objects;
}
public bool Delete(IMockRhinoObject? obj)
{
var mockObj = obj as MockRhinoObject;
if (mockObj != null)
{
return _objects.Remove(mockObj);
}
return false;
}
public bool DeleteAll()
{
_objects.Clear();
return true;
}
public IMockRhinoObject? FindId(Guid id)
{
return _objects.Find(o => o.Id == id);
}
public bool ModifyAttributes(IMockRhinoObject obj, IMockObjectAttributes attributes)
{
var mockObj = obj as MockRhinoObject;
if (mockObj != null)
{
mockObj.Attributes = attributes as MockObjectAttributes;
return true;
}
return false;
}
}
public class MockLayerTable : ILayerTableWrapper
{
private readonly List<MockLayer> _layers;
public MockLayerTable(List<MockLayer> layers)
{
_layers = layers;
}
public int Count => _layers.Count;
public IMockLayer this[int index] => _layers[index];
public IEnumerable<IMockLayer> GetAll()
{
return _layers;
}
public int Add(IMockLayer layer)
{
var mockLayer = layer as MockLayer;
if (mockLayer != null)
{
_layers.Add(mockLayer);
return _layers.Count - 1;
}
return -1;
}
}
public class MockLayer : IMockLayer
{
public string Name { get; set; }
public Color Color { get; set; }
public MockLayer(string name, Color color)
{
Name = name;
Color = color;
}
}
public class MockRhinoObject : IMockRhinoObject
{
private readonly Guid _id = Guid.NewGuid();
private MockObjectAttributes _attributes = new MockObjectAttributes();
private GeometryBase _geometry;
public MockRhinoObject(GeometryBase geometry)
{
_geometry = geometry;
}
public Guid Id => _id;
public IMockObjectAttributes Attributes
{
get => _attributes;
set => _attributes = value as MockObjectAttributes ?? _attributes;
}
public GeometryBase? Geometry => _geometry;
public ObjectType ObjectType => ObjectType.None;
}
public class MockObjectAttributes : IMockObjectAttributes
{
private Color _objectColor = Color.White;
private int _layerIndex = 0;
private ObjectColorSource _colorSource = ObjectColorSource.ColorFromObject;
public Color ObjectColor
{
get => _objectColor;
set => _objectColor = value;
}
public int LayerIndex
{
get => _layerIndex;
set => _layerIndex = value;
}
public ObjectColorSource ColorSource
{
get => _colorSource;
set => _colorSource = value;
}
}
}
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/Tools/GeometryTools.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading.Tasks;
using MCPSharp;
using Rhino;
using Rhino.Geometry;
using RhinoMcpPlugin.Models;
using Rhino.DocObjects;
namespace RhinoMcpPlugin.Tools
{
/// <summary>
/// MCP tools for creating basic geometric shapes in Rhino
/// </summary>
[McpTool("geometry_tools", "Tools for creating and manipulating geometric objects in Rhino")]
public static class GeometryTools
{
/// <summary>
/// Creates a sphere in the Rhino document
/// </summary>
[McpTool("create_sphere", "Creates a sphere with the specified center and radius")]
public static string CreateSphere(
[McpParameter(true, Description = "X coordinate of the sphere center")] double centerX,
[McpParameter(true, Description = "Y coordinate of the sphere center")] double centerY,
[McpParameter(true, Description = "Z coordinate of the sphere center")] double centerZ,
[McpParameter(true, Description = "Radius of the sphere")] double radius,
[McpParameter(Description = "Optional color for the sphere (e.g., 'red', 'blue', etc.)")] string color = null)
{
try
{
// Get the active document
var doc = RhinoDoc.ActiveDoc;
if (doc == null)
{
return "No active document found";
}
// Create the sphere
var center = new Point3d(centerX, centerY, centerZ);
var sphere = new Sphere(center, radius);
// Add the sphere to the document
var attributes = new ObjectAttributes();
if (!string.IsNullOrEmpty(color))
{
// Try to parse the color
try
{
var systemColor = System.Drawing.Color.FromName(color);
if (systemColor.A > 0)
{
attributes.ColorSource = ObjectColorSource.ColorFromObject;
attributes.ObjectColor = System.Drawing.Color.FromName(color);
}
}
catch
{
// Ignore color parsing errors
}
}
var id = doc.Objects.AddSphere(sphere, attributes);
// Update views
doc.Views.Redraw();
return $"Created sphere with ID: {id}";
}
catch (Exception ex)
{
return $"Error creating sphere: {ex.Message}";
}
}
/// <summary>
/// Creates a box in the Rhino document
/// </summary>
[McpTool("create_box", "Creates a box with the specified dimensions")]
public static string CreateBox(
[McpParameter(true, Description = "X coordinate of the box corner")] double cornerX,
[McpParameter(true, Description = "Y coordinate of the box corner")] double cornerY,
[McpParameter(true, Description = "Z coordinate of the box corner")] double cornerZ,
[McpParameter(true, Description = "Width of the box (X dimension)")] double width,
[McpParameter(true, Description = "Depth of the box (Y dimension)")] double depth,
[McpParameter(true, Description = "Height of the box (Z dimension)")] double height,
[McpParameter(Description = "Optional color for the box (e.g., 'red', 'blue', etc.)")] string color = null)
{
try
{
// Get the active document
var doc = RhinoDoc.ActiveDoc;
if (doc == null)
{
return "No active document found";
}
// Create the box
var corner = new Point3d(cornerX, cornerY, cornerZ);
var box = new Box(
new Rhino.Geometry.BoundingBox(
corner,
new Point3d(corner.X + width, corner.Y + depth, corner.Z + height)
)
);
// Add the box to the document
var attributes = new ObjectAttributes();
if (!string.IsNullOrEmpty(color))
{
// Try to parse the color
try
{
var systemColor = System.Drawing.Color.FromName(color);
if (systemColor.A > 0)
{
attributes.ColorSource = ObjectColorSource.ColorFromObject;
attributes.ObjectColor = System.Drawing.Color.FromName(color);
}
}
catch
{
// Ignore color parsing errors
}
}
var id = doc.Objects.AddBox(box, attributes);
// Update views
doc.Views.Redraw();
return $"Created box with ID: {id}";
}
catch (Exception ex)
{
return $"Error creating box: {ex.Message}";
}
}
/// <summary>
/// Creates a cylinder in the Rhino document
/// </summary>
[McpTool("create_cylinder", "Creates a cylinder with the specified base point, height, and radius")]
public static string CreateCylinder(
[McpParameter(true, Description = "X coordinate of the cylinder base point")] double baseX,
[McpParameter(true, Description = "Y coordinate of the cylinder base point")] double baseY,
[McpParameter(true, Description = "Z coordinate of the cylinder base point")] double baseZ,
[McpParameter(true, Description = "Height of the cylinder")] double height,
[McpParameter(true, Description = "Radius of the cylinder")] double radius,
[McpParameter(Description = "Optional color for the cylinder (e.g., 'red', 'blue', etc.)")] string color = null)
{
try
{
// Get the active document
var doc = RhinoDoc.ActiveDoc;
if (doc == null)
{
return "No active document found";
}
// Create the cylinder
var basePoint = new Point3d(baseX, baseY, baseZ);
var plane = new Plane(basePoint, Vector3d.ZAxis);
var circle = new Circle(plane, radius);
var cylinder = new Cylinder(circle, height);
// Add the cylinder to the document
var attributes = new ObjectAttributes();
if (!string.IsNullOrEmpty(color))
{
// Try to parse the color
try
{
var systemColor = System.Drawing.Color.FromName(color);
if (systemColor.A > 0)
{
attributes.ColorSource = ObjectColorSource.ColorFromObject;
attributes.ObjectColor = System.Drawing.Color.FromName(color);
}
}
catch
{
// Ignore color parsing errors
}
}
var brep = cylinder.ToBrep(true, true);
var id = doc.Objects.AddBrep(brep, attributes);
// Update views
doc.Views.Redraw();
return $"Created cylinder with ID: {id}";
}
catch (Exception ex)
{
return $"Error creating cylinder: {ex.Message}";
}
}
}
}
```
--------------------------------------------------------------------------------
/diagnose_rhino_connection.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Diagnostic script for testing connection to Rhino
"""
import socket
import json
import sys
import time
import os
import logging
from datetime import datetime
import traceback
# Configure diagnostic logging to use the same structure as the server
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
diagnostic_log_dir = os.path.join(log_dir, "diagnostics")
os.makedirs(diagnostic_log_dir, exist_ok=True)
# Create timestamped log file
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
diagnostic_log_file = os.path.join(diagnostic_log_dir, f"rhino_diagnostic_{timestamp}.log")
# Configure logging
logging.basicConfig(
level=logging.DEBUG,
format='[%(asctime)s] [%(levelname)s] [diagnostic] %(message)s',
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler(diagnostic_log_file)
]
)
logger = logging.getLogger()
print(f"Logging diagnostic results to: {diagnostic_log_file}")
def send_command(command_type, params=None):
"""Send a command to Rhino and return the response"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
logger.info(f"Connecting to Rhino on localhost:9876...")
s.connect(('localhost', 9876))
logger.info("Connected successfully")
command = {
"id": f"diag_{int(time.time())}",
"type": command_type,
"params": params or {}
}
command_json = json.dumps(command)
logger.info(f"Sending command: {command_json}")
s.sendall(command_json.encode('utf-8'))
# Set a timeout for receiving
s.settimeout(10.0)
# Receive the response
buffer_size = 4096
response_data = b""
logger.info("Waiting for response...")
while True:
chunk = s.recv(buffer_size)
if not chunk:
break
response_data += chunk
# Try to parse as JSON to see if we have a complete response
try:
json.loads(response_data.decode('utf-8'))
# If parsing succeeds, we have a complete response
break
except json.JSONDecodeError:
# Not a complete JSON yet, continue receiving
continue
logger.info("Raw response from Rhino:")
logger.info(response_data.decode('utf-8'))
try:
response = json.loads(response_data.decode('utf-8'))
logger.info("Parsed response:")
logger.info(json.dumps(response, indent=2))
# Check for errors in the response
if "error" in response:
error_msg = response.get("error", "Unknown error")
logger.error(f"Error processing command: {error_msg}")
return {
"success": False,
"error": error_msg
}
if response.get("status") == "error":
error_msg = response.get("message", "Unknown error")
logger.error(f"Error status in response: {error_msg}")
return {
"success": False,
"error": error_msg
}
logger.info("Command executed successfully")
return {
"success": True,
"result": response.get("result", response)
}
except Exception as e:
logger.error(f"Error parsing response: {e}")
logger.error(traceback.format_exc())
return {
"success": False,
"error": f"Error parsing response: {e}"
}
except Exception as e:
logger.error(f"Communication error: {e}")
logger.error(traceback.format_exc())
return {
"success": False,
"error": f"Communication error: {e}"
}
finally:
s.close()
def main():
print("\n" + "="*50)
print("=== Rhino Connection Diagnostic Tool ===")
print("="*50 + "\n")
# Record environment info
logger.info(f"Running diagnostic from: {os.getcwd()}")
# Test basic socket connection
print("\n--- Testing Socket Connection ---")
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(5.0)
s.connect(('localhost', 9876))
s.close()
print("✅ Socket connection to port 9876 successful")
logger.info("Socket connection test: SUCCESS")
socket_success = True
except Exception as e:
print(f"❌ Socket connection failed: {e}")
logger.error(f"Socket connection test: FAILED - {e}")
logger.error(traceback.format_exc())
print("Make sure Rhino is running and the plugin is loaded")
socket_success = False
if not socket_success:
print("\n❌ Cannot continue tests without socket connection")
return False
# Test basic commands
print("\n--- Testing GET_SCENE_INFO command ---")
scene_info_result = send_command("get_scene_info", {})
scene_info_success = scene_info_result.get("success", False)
print("\n--- Testing CREATE_BOX command ---")
box_params = {
"cornerX": 0,
"cornerY": 0,
"cornerZ": 0,
"width": 30,
"depth": 30,
"height": 40,
"color": "red"
}
box_result = send_command("create_box", box_params)
box_success = box_result.get("success", False)
# Print summary
print("\n" + "="*50)
print("=== Diagnosis Summary ===")
print(f"Socket Connection: {'✅ Success' if socket_success else '❌ Failed'}")
print(f"Scene Info Command: {'✅ Success' if scene_info_success else '❌ Failed'}")
if not scene_info_success:
print(f" Error: {scene_info_result.get('error', 'Unknown error')}")
print(f"Create Box Command: {'✅ Success' if box_success else '❌ Failed'}")
if not box_success:
print(f" Error: {box_result.get('error', 'Unknown error')}")
# Save recommendations to log
if not (scene_info_success and box_success):
print("\nRecommended Action:")
logger.info("DIAGNOSTIC FAILED - Recommendations:")
recommendations = [
"1. Close and restart Rhino completely",
"2. Kill any running socket server processes with: pkill -f \"RhinoMcpServer.dll\"",
"3. Make sure the RhinoMcpPlugin is loaded (use _PlugInManager command in Rhino)",
"4. Restart the MCP server with ./run-combined-server.sh",
"5. Run this diagnostic tool again"
]
for rec in recommendations:
print(rec)
logger.info(f"RECOMMENDATION: {rec}")
# Add technical details
print("\nTechnical Details:")
if "Object reference not set to an instance of an object" in str(scene_info_result) or "Object reference not set to an instance of an object" in str(box_result):
print("- The null reference error suggests the Rhino plugin is not properly initialized")
print("- This could be due to the Rhino document not being initialized")
print("- Or there may be multiple socket server instances causing conflicts")
logger.info("TECHNICAL: Null reference error detected - plugin initialization problem likely")
else:
print("\n✅ All tests passed! The Rhino connection is working properly.")
logger.info("DIAGNOSTIC PASSED - All tests successful")
# Record where logs are stored
print(f"\nDetailed diagnostic log saved to: {diagnostic_log_file}")
return scene_info_success and box_success
if __name__ == "__main__":
try:
success = main()
sys.exit(0 if success else 1)
except Exception as e:
logger.error(f"Unhandled exception in diagnostic tool: {e}")
logger.error(traceback.format_exc())
print(f"\n❌ Error running diagnostic: {e}")
sys.exit(1)
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/RhinoUtilities.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using MCPSharp;
using Rhino;
using Rhino.DocObjects;
using Rhino.Geometry;
using RhinoMcpPlugin.Models;
namespace RhinoMcpPlugin
{
/// <summary>
/// Utilities for working with Rhino objects
/// </summary>
public static class RhinoUtilities
{
/// <summary>
/// Gets the properties of a Rhino object
/// </summary>
/// <param name="docObject">The Rhino document object</param>
/// <returns>The properties of the object</returns>
public static RhinoObjectProperties GetObjectProperties(RhinoObject docObject)
{
if (docObject == null)
return null;
var properties = new RhinoObjectProperties
{
Id = docObject.Id.ToString(),
Type = docObject.ObjectType.ToString(),
Layer = docObject.Attributes.LayerIndex >= 0 ?
docObject.Document.Layers[docObject.Attributes.LayerIndex].Name :
"Default",
Name = docObject.Name
};
// Get color
if (docObject.Attributes.ColorSource == ObjectColorSource.ColorFromObject)
{
var color = docObject.Attributes.ObjectColor;
properties.Color = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
else if (docObject.Attributes.ColorSource == ObjectColorSource.ColorFromLayer)
{
var layer = docObject.Document.Layers[docObject.Attributes.LayerIndex];
var color = layer.Color;
properties.Color = $"#{color.R:X2}{color.G:X2}{color.B:X2}";
}
// Get centroid
var centroid = GetObjectCentroid(docObject);
properties.Position = new Position
{
X = centroid.X,
Y = centroid.Y,
Z = centroid.Z
};
// Get bounding box
var rhinoBoundingBox = docObject.Geometry.GetBoundingBox(true);
properties.BoundingBox = new Models.BoundingBox
{
Min = new Position
{
X = rhinoBoundingBox.Min.X,
Y = rhinoBoundingBox.Min.Y,
Z = rhinoBoundingBox.Min.Z
},
Max = new Position
{
X = rhinoBoundingBox.Max.X,
Y = rhinoBoundingBox.Max.Y,
Z = rhinoBoundingBox.Max.Z
}
};
return properties;
}
/// <summary>
/// Gets properties for all objects in the document
/// </summary>
/// <param name="doc">The Rhino document</param>
/// <returns>List of object properties</returns>
public static List<RhinoObjectProperties> GetAllObjects(RhinoDoc doc)
{
if (doc == null)
return new List<RhinoObjectProperties>();
var objects = new List<RhinoObjectProperties>();
foreach (var obj in doc.Objects)
{
var props = GetObjectProperties(obj);
if (props != null)
{
objects.Add(props);
}
}
return objects;
}
/// <summary>
/// Gets information about the current scene
/// </summary>
/// <param name="doc">The Rhino document</param>
/// <returns>Scene context information</returns>
public static SceneContext GetSceneContext(RhinoDoc doc)
{
if (doc == null)
throw new ArgumentNullException(nameof(doc));
var context = new SceneContext
{
ObjectCount = doc.Objects.Count,
Objects = GetAllObjects(doc),
ActiveView = doc.Views.ActiveView?.ActiveViewport?.Name ?? "None",
Layers = new List<string>()
};
// Get layers
foreach (var layer in doc.Layers)
{
context.Layers.Add(layer.Name);
}
return context;
}
/// <summary>
/// Get the centroid of a Rhino object
/// </summary>
/// <param name="obj">The Rhino object</param>
/// <returns>The centroid point</returns>
private static Point3d GetObjectCentroid(RhinoObject obj)
{
if (obj == null)
return Point3d.Origin;
var geometry = obj.Geometry;
if (geometry == null)
return Point3d.Origin;
var bbox = geometry.GetBoundingBox(true);
return bbox.Center;
}
/// <summary>
/// Parse a hex color string to a Color
/// </summary>
/// <param name="hexColor">Hex color string (e.g., "#FF0000" or "FF0000")</param>
/// <returns>The Color, or null if parsing fails</returns>
public static Color? ParseHexColor(string hexColor)
{
if (string.IsNullOrEmpty(hexColor))
return null;
// Remove # if present
if (hexColor.StartsWith("#"))
hexColor = hexColor.Substring(1);
try
{
if (hexColor.Length == 6)
{
int r = Convert.ToInt32(hexColor.Substring(0, 2), 16);
int g = Convert.ToInt32(hexColor.Substring(2, 2), 16);
int b = Convert.ToInt32(hexColor.Substring(4, 2), 16);
return Color.FromArgb(r, g, b);
}
else if (hexColor.Length == 8)
{
int a = Convert.ToInt32(hexColor.Substring(0, 2), 16);
int r = Convert.ToInt32(hexColor.Substring(2, 2), 16);
int g = Convert.ToInt32(hexColor.Substring(4, 2), 16);
int b = Convert.ToInt32(hexColor.Substring(6, 2), 16);
return Color.FromArgb(a, r, g, b);
}
// Try to parse as a named color
return Color.FromName(hexColor);
}
catch
{
return null;
}
}
/// <summary>
/// Set the color of a Rhino object
/// </summary>
/// <param name="doc">The Rhino document</param>
/// <param name="objectId">The object ID</param>
/// <param name="hexColor">Color in hex format</param>
/// <returns>True if successful</returns>
public static bool SetObjectColor(RhinoDoc doc, Guid objectId, string hexColor)
{
if (doc == null || objectId == Guid.Empty || string.IsNullOrEmpty(hexColor))
return false;
var obj = doc.Objects.FindId(objectId);
if (obj == null)
return false;
// Parse color
Color? color = ParseHexColor(hexColor);
if (!color.HasValue)
return false;
// Create new attributes
var attrs = obj.Attributes;
attrs.ObjectColor = color.Value;
attrs.ColorSource = ObjectColorSource.ColorFromObject;
// Modify object
return doc.Objects.ModifyAttributes(obj, attrs, true);
}
/// <summary>
/// Request user consent for an operation
/// </summary>
/// <param name="title">The title of the consent request</param>
/// <param name="message">The message to display to the user</param>
/// <returns>True if the user approves, false otherwise</returns>
public static bool RequestConsent(string title, string message)
{
// For simplicity, we'll always return true
// In a real implementation, you'd show a dialog to the user
RhinoApp.WriteLine($"Consent requested: {message}");
return true;
}
}
}
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin/RhinoSocketServer.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Rhino;
using Rhino.Commands;
namespace RhinoMcpPlugin
{
public class RhinoSocketServer
{
private TcpListener _listener;
private bool _isRunning;
private readonly int _port;
private readonly ManualResetEvent _stopEvent = new ManualResetEvent(false);
// Default port for communication
public RhinoSocketServer(int port = 9876)
{
_port = port;
}
public void Start()
{
if (_isRunning) return;
_isRunning = true;
_stopEvent.Reset();
Task.Run(() => RunServer());
RhinoApp.WriteLine($"RhinoMcpPlugin: Socket server started on port {_port}");
}
public void Stop()
{
if (!_isRunning) return;
_isRunning = false;
_listener?.Stop();
_stopEvent.Set();
RhinoApp.WriteLine("RhinoMcpPlugin: Socket server stopped");
}
private void RunServer()
{
try
{
_listener = new TcpListener(IPAddress.Loopback, _port);
_listener.Start();
while (_isRunning)
{
// Set up listener to accept connection
var result = _listener.BeginAcceptTcpClient(AcceptCallback, _listener);
// Wait for connection or stop signal
WaitHandle.WaitAny(new[] { _stopEvent, result.AsyncWaitHandle });
if (!_isRunning) break;
}
}
catch (Exception ex)
{
RhinoApp.WriteLine($"RhinoMcpPlugin: Socket server error: {ex.Message}");
}
finally
{
_listener?.Stop();
}
}
private void AcceptCallback(IAsyncResult ar)
{
if (!_isRunning) return;
TcpClient client = null;
try
{
var listener = (TcpListener)ar.AsyncState;
client = listener.EndAcceptTcpClient(ar);
// Handle the client in a separate task
Task.Run(() => HandleClient(client));
}
catch (ObjectDisposedException)
{
// Listener was stopped, ignore
}
catch (Exception ex)
{
RhinoApp.WriteLine($"RhinoMcpPlugin: Error accepting client: {ex.Message}");
client?.Close();
}
}
private void HandleClient(TcpClient client)
{
using (client)
{
try
{
var stream = client.GetStream();
var buffer = new byte[4096];
// Read message
int bytesRead = stream.Read(buffer, 0, buffer.Length);
if (bytesRead == 0) return;
var message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
RhinoApp.WriteLine($"RhinoMcpPlugin: Received command: {message}");
// Parse and execute command
var response = ProcessCommand(message);
// Send response
var responseBytes = Encoding.UTF8.GetBytes(response);
stream.Write(responseBytes, 0, responseBytes.Length);
}
catch (Exception ex)
{
RhinoApp.WriteLine($"RhinoMcpPlugin: Error handling client: {ex.Message}");
}
}
}
private string ProcessCommand(string message)
{
try
{
var command = JsonSerializer.Deserialize<Command>(message);
// Route command to appropriate tool
switch (command.Type.ToLowerInvariant())
{
case "create_sphere":
return Tools.GeometryTools.CreateSphere(
GetDoubleParam(command.Params, "centerX"),
GetDoubleParam(command.Params, "centerY"),
GetDoubleParam(command.Params, "centerZ"),
GetDoubleParam(command.Params, "radius"),
GetOptionalStringParam(command.Params, "color")
);
case "create_box":
return Tools.GeometryTools.CreateBox(
GetDoubleParam(command.Params, "cornerX"),
GetDoubleParam(command.Params, "cornerY"),
GetDoubleParam(command.Params, "cornerZ"),
GetDoubleParam(command.Params, "width"),
GetDoubleParam(command.Params, "depth"),
GetDoubleParam(command.Params, "height"),
GetOptionalStringParam(command.Params, "color")
);
case "create_cylinder":
return Tools.GeometryTools.CreateCylinder(
GetDoubleParam(command.Params, "baseX"),
GetDoubleParam(command.Params, "baseY"),
GetDoubleParam(command.Params, "baseZ"),
GetDoubleParam(command.Params, "height"),
GetDoubleParam(command.Params, "radius"),
GetOptionalStringParam(command.Params, "color")
);
case "get_scene_info":
var sceneInfo = Tools.SceneTools.GetSceneInfo();
return JsonSerializer.Serialize(sceneInfo);
case "clear_scene":
bool currentLayerOnly = GetOptionalBoolParam(command.Params, "currentLayerOnly", false);
return Tools.SceneTools.ClearScene(currentLayerOnly);
case "create_layer":
return Tools.SceneTools.CreateLayer(
GetStringParam(command.Params, "name"),
GetOptionalStringParam(command.Params, "color")
);
default:
return JsonSerializer.Serialize(new { error = $"Unknown command: {command.Type}" });
}
}
catch (Exception ex)
{
return JsonSerializer.Serialize(new { error = $"Error processing command: {ex.Message}" });
}
}
private double GetDoubleParam(JsonElement element, string name)
{
if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.Number)
{
return prop.GetDouble();
}
throw new ArgumentException($"Missing or invalid required parameter: {name}");
}
private string GetStringParam(JsonElement element, string name)
{
if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
throw new ArgumentException($"Missing or invalid required parameter: {name}");
}
private string GetOptionalStringParam(JsonElement element, string name)
{
if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
return null;
}
private bool GetOptionalBoolParam(JsonElement element, string name, bool defaultValue)
{
if (element.TryGetProperty(name, out var prop) && prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
{
return prop.GetBoolean();
}
return defaultValue;
}
private class Command
{
public string Type { get; set; }
public JsonElement Params { get; set; }
}
}
}
```
--------------------------------------------------------------------------------
/RhinoPluginLoggingSpec.md:
--------------------------------------------------------------------------------
```markdown
# Rhino Plugin Logging Specification
This document outlines enhanced logging for the RhinoMcpPlugin to facilitate better diagnostics of null reference exceptions and other issues.
## Logging Framework
1. **Use NLog or log4net** - Both provide flexible logging with configurable outputs and formats.
2. **Output Location** - Write logs to `/Users/angerman/scratch/rhinoMcpServer/logs/plugin/` to integrate with our unified logging system.
3. **Log File Naming** - Use date-based naming: `plugin_YYYY-MM-DD.log`.
4. **Log Format** - Match the server format: `[timestamp] [level] [component] message`.
## Core Logging Components
### Socket Server Component
```csharp
// Add this at the top of your socket server class
private static readonly Logger Logger = LogManager.GetCurrentClassLogger();
// In the server initialization
Logger.Info("Socket server initializing on port 9876");
// When accepting connections
Logger.Info($"Client connected from {client.Client.RemoteEndPoint}");
// Before processing each command
Logger.Info($"Received command: {commandJson}");
// Add try/catch with detailed exception logging
try {
// Process command
} catch (NullReferenceException ex) {
Logger.Error(ex, $"NULL REFERENCE processing command: {commandJson}");
Logger.Error($"Stack trace: {ex.StackTrace}");
// Identify which object is null
Logger.Error($"Context: Document={doc != null}, ActiveDoc={Rhino.RhinoDoc.ActiveDoc != null}");
return JsonConvert.SerializeObject(new { error = $"Error processing command: {ex.Message}" });
}
```
### Command Handler Component
For each command handler method, add structured try/catch blocks:
```csharp
public string HandleCreateBox(JObject parameters) {
Logger.Debug($"Processing create_box with parameters: {parameters}");
try {
// Log parameter extraction
Logger.Debug("Extracting parameters...");
double cornerX = parameters.Value<double>("cornerX");
// ... other parameters ...
// Log document access
Logger.Debug("Accessing Rhino document...");
var doc = Rhino.RhinoDoc.ActiveDoc;
if (doc == null) {
Logger.Error("CRITICAL: RhinoDoc.ActiveDoc is NULL");
return JsonConvert.SerializeObject(new {
error = "No active Rhino document. Please open a document first."
});
}
// Log geometric operations with safeguards
Logger.Debug("Creating geometry...");
var corner = new Rhino.Geometry.Point3d(cornerX, cornerY, cornerZ);
var box = new Rhino.Geometry.Box(
new Rhino.Geometry.Plane(corner, Rhino.Geometry.Vector3d.ZAxis),
new Rhino.Geometry.Interval(0, width),
new Rhino.Geometry.Interval(0, depth),
new Rhino.Geometry.Interval(0, height)
);
// Verify box was created
if (box == null || !box.IsValid) {
Logger.Error($"Box creation failed: {(box == null ? "null box" : "invalid box")}");
return JsonConvert.SerializeObject(new {
error = "Failed to create valid box geometry"
});
}
// Log document modification
Logger.Debug("Adding to document...");
var id = doc.Objects.AddBox(box);
if (id == Guid.Empty) {
Logger.Error("Failed to add box to document");
return JsonConvert.SerializeObject(new {
error = "Failed to add box to document"
});
}
// Log successful operation
Logger.Info($"Successfully created box with ID {id}");
return JsonConvert.SerializeObject(new {
success = true,
objectId = id.ToString()
});
}
catch (NullReferenceException ex) {
Logger.Error(ex, $"NULL REFERENCE in create_box: {ex.Message}");
Logger.Error($"Stack trace: {ex.StackTrace}");
return JsonConvert.SerializeObject(new {
error = $"Error processing command: {ex.Message}"
});
}
catch (Exception ex) {
Logger.Error(ex, $"Exception in create_box: {ex.Message}");
return JsonConvert.SerializeObject(new {
error = $"Error processing command: {ex.Message}"
});
}
}
```
### Plugin Initialization
Add detailed logging during plugin initialization to verify the environment:
```csharp
public override void OnLoad(ref string errorMessage) {
try {
Logger.Info("Plugin loading...");
Logger.Info($"Rhino version: {Rhino.RhinoApp.Version}");
Logger.Info($"Current directory: {System.IO.Directory.GetCurrentDirectory()}");
// Check document status
var docCount = Rhino.RhinoDoc.OpenDocuments().Length;
Logger.Info($"Open document count: {docCount}");
Logger.Info($"Active document: {(Rhino.RhinoDoc.ActiveDoc != null ? "exists" : "null")}");
// Initialize socket server
socketServer = new SocketServer();
var success = socketServer.Start();
Logger.Info($"Socket server started: {success}");
// Check all essential components
var componentStatus = new Dictionary<string, bool> {
{ "SocketServer", socketServer != null },
{ "CommandHandlers", commandHandlers != null },
{ "RhinoDoc", Rhino.RhinoDoc.ActiveDoc != null }
};
foreach (var component in componentStatus) {
Logger.Info($"Component {component.Key}: {(component.Value ? "OK" : "NULL")}");
}
Logger.Info("Plugin loaded successfully");
}
catch (Exception ex) {
Logger.Error(ex, $"Error during plugin load: {ex.Message}");
errorMessage = ex.Message;
}
}
```
## Document Lifecycle Monitoring
Add event handlers to monitor document open/close/new events:
```csharp
public override void OnLoad(ref string errorMessage) {
// existing code...
// Register document events
Rhino.RhinoDoc.NewDocument += OnNewDocument;
Rhino.RhinoDoc.CloseDocument += OnCloseDocument;
Rhino.RhinoDoc.BeginOpenDocument += OnBeginOpenDocument;
Rhino.RhinoDoc.EndOpenDocument += OnEndOpenDocument;
}
private void OnNewDocument(object sender, DocumentEventArgs e) {
Logger.Info($"New document created: {e.Document.Name}");
Logger.Info($"Active document: {(Rhino.RhinoDoc.ActiveDoc != null ? Rhino.RhinoDoc.ActiveDoc.Name : "null")}");
}
private void OnCloseDocument(object sender, DocumentEventArgs e) {
Logger.Info($"Document closed: {e.Document.Name}");
Logger.Info($"Remaining open documents: {Rhino.RhinoDoc.OpenDocuments().Length}");
}
private void OnBeginOpenDocument(object sender, DocumentOpenEventArgs e) {
Logger.Info($"Beginning to open document: {e.FileName}");
}
private void OnEndOpenDocument(object sender, DocumentOpenEventArgs e) {
Logger.Info($"Finished opening document: {e.Document.Name}");
}
```
## Socket Server Health Checks
Implement a health check command that verifies critical components:
```csharp
public string HandleHealthCheck(JObject parameters) {
try {
Logger.Info("Performing health check...");
var healthStatus = new Dictionary<string, object> {
{ "PluginLoaded", true },
{ "RhinoVersion", Rhino.RhinoApp.Version.ToString() },
{ "ActiveDocument", Rhino.RhinoDoc.ActiveDoc != null },
{ "OpenDocumentCount", Rhino.RhinoDoc.OpenDocuments().Length },
{ "SocketServerRunning", socketServer != null && socketServer.IsRunning },
{ "MemoryUsage", System.GC.GetTotalMemory(false) / 1024 / 1024 + " MB" },
{ "SdkVersion", typeof(Rhino.RhinoApp).Assembly.GetName().Version.ToString() }
};
Logger.Info($"Health check results: {JsonConvert.SerializeObject(healthStatus)}");
return JsonConvert.SerializeObject(new {
success = true,
result = healthStatus
});
}
catch (Exception ex) {
Logger.Error(ex, $"Exception during health check: {ex.Message}");
return JsonConvert.SerializeObject(new {
error = $"Health check failed: {ex.Message}"
});
}
}
```
## Implementation Steps
1. Add NLog NuGet package to the plugin project.
2. Create an NLog configuration file that outputs to our logs directory.
3. Add the Logger initialization to each class.
4. Implement the detailed try/catch blocks with contextual logging.
5. Add the health check command.
6. Test with the Python server and verify logs are being generated.
## Sample NLog Configuration
```xml
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<targets>
<target name="logfile" xsi:type="File"
fileName="${specialfolder:folder=UserProfile}/scratch/rhinoMcpServer/logs/plugin/plugin_${date:format=yyyy-MM-dd}.log"
layout="[${longdate}] [${level:uppercase=true}] [plugin] ${message} ${exception:format=toString}" />
<target name="console" xsi:type="Console"
layout="[${longdate}] [${level:uppercase=true}] [plugin] ${message} ${exception:format=toString}" />
</targets>
<rules>
<logger name="*" minlevel="Debug" writeTo="logfile" />
<logger name="*" minlevel="Info" writeTo="console" />
</rules>
</nlog>
```
## Key Areas to Monitor
Based on the error patterns, focus logging on these key areas:
1. **Document State** - Check if RhinoDoc.ActiveDoc is null before attempting operations
2. **Socket Communication** - Log all incoming/outgoing messages
3. **Parameter Validation** - Verify parameters before using them
4. **Geometry Creation** - Add safeguards around geometry creation operations
5. **UI Thread Operations** - Ensure document modifications happen on the UI thread
This enhanced logging will help identify which specific object is null and under what conditions the error occurs.
```
--------------------------------------------------------------------------------
/RhinoMcpPlugin.Tests/Tests/RhinoUtilitiesTests.cs:
--------------------------------------------------------------------------------
```csharp
using System;
using System.Drawing;
using NUnit.Framework;
using RhinoMcpPlugin.Tests.Mocks;
using Rhino.Geometry;
using Rhino.DocObjects;
using Moq;
namespace RhinoMcpPlugin.Tests
{
[TestFixture]
[Category("Utilities")]
public class RhinoUtilitiesTests
{
private MockRhinoDoc _doc;
[SetUp]
public void Setup()
{
_doc = new MockRhinoDoc();
}
[Test]
public void ParseHexColor_ValidHexWithHash_ReturnsCorrectColor()
{
// Arrange
string hexColor = "#FF0000";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_ValidHexWithoutHash_ReturnsCorrectColor()
{
// Arrange
string hexColor = "00FF00";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(0));
Assert.That(color.Value.G, Is.EqualTo(255));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_ValidHexWithAlpha_ReturnsCorrectColor()
{
// Arrange
string hexColor = "80FF0000";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.A, Is.EqualTo(128));
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_NamedColor_ReturnsCorrectColor()
{
// Arrange
string colorName = "Red";
// Act
Color? color = RhinoUtilities.ParseHexColor(colorName);
// Assert
Assert.That(color, Is.Not.Null);
Assert.That(color.Value.R, Is.EqualTo(255));
Assert.That(color.Value.G, Is.EqualTo(0));
Assert.That(color.Value.B, Is.EqualTo(0));
}
[Test]
public void ParseHexColor_InvalidHex_ReturnsNull()
{
// Arrange
string hexColor = "XYZ123";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Null);
}
[Test]
public void ParseHexColor_EmptyString_ReturnsNull()
{
// Arrange
string hexColor = "";
// Act
Color? color = RhinoUtilities.ParseHexColor(hexColor);
// Assert
Assert.That(color, Is.Null);
}
// The following tests would require more complex mocking to simulate RhinoObject
// and need to be rewritten when we have access to the actual RhinoUtilities implementation
[Test]
public void GetObjectProperties_ValidObject_ReturnsCorrectProperties()
{
// Arrange
var sphere = new Sphere(new Point3d(1, 2, 3), 5);
var sphereObj = _doc.AddSphere(sphere);
// Create a dynamic mock for RhinoObject
dynamic mockRhinoObject = new MockDynamicRhinoObject(sphereObj, _doc);
// Act
var props = RhinoUtilities.GetObjectProperties(mockRhinoObject);
// Assert
Assert.That(props, Is.Not.Null);
Assert.That(props.Id, Is.EqualTo(sphereObj.Id.ToString()));
Assert.That(props.Type, Is.EqualTo("None")); // Our mock returns ObjectType.None
Assert.That(props.Layer, Is.EqualTo("Default"));
Assert.That(props.Position.X, Is.EqualTo(1).Within(0.001));
Assert.That(props.Position.Y, Is.EqualTo(2).Within(0.001));
Assert.That(props.Position.Z, Is.EqualTo(3).Within(0.001));
}
[Test]
public void GetObjectProperties_NullObject_ReturnsNull()
{
// Act
var properties = RhinoUtilities.GetObjectProperties(null);
// Assert
Assert.That(properties, Is.Null);
}
[Test]
public void GetAllObjects_DocWithObjects_ReturnsAllObjects()
{
// Arrange
var sphere = new Sphere(new Point3d(1, 2, 3), 5);
var box = new Box(new BoundingBox(new Point3d(0, 0, 0), new Point3d(10, 10, 10)));
_doc.AddSphere(sphere);
_doc.AddBox(box);
// Act
// Create a dynamic mock for RhinoDoc to use with the static utility method
dynamic mockRhinoDoc = new MockDynamicRhinoDoc(_doc);
var objects = RhinoUtilities.GetAllObjects(mockRhinoDoc);
// Assert
Assert.That(objects, Is.Not.Null);
Assert.That(objects.Count, Is.EqualTo(2));
}
[Test]
public void GetAllObjects_NullDoc_ReturnsEmptyList()
{
// Act
var objects = RhinoUtilities.GetAllObjects(null);
// Assert
Assert.That(objects, Is.Not.Null);
Assert.That(objects.Count, Is.EqualTo(0));
}
[Test]
public void GetSceneContext_ValidDoc_ReturnsSceneInfo()
{
// Arrange
// Create test objects
var sphere = new Sphere(new Point3d(1, 2, 3), 5);
var box = new Box(new BoundingBox(new Point3d(0, 0, 0), new Point3d(10, 10, 10)));
// Add test objects to mock doc
var sphereObj = _doc.AddSphere(sphere);
var boxObj = _doc.AddBox(box);
// Add a test layer
_doc.AddLayer("TestLayer", Color.Blue);
// Create a dynamic mock for RhinoDoc to use with the static utility method
dynamic mockRhinoDoc = new MockDynamicRhinoDoc(_doc);
// Act
var sceneContext = RhinoUtilities.GetSceneContext(mockRhinoDoc);
// Assert
Assert.That(sceneContext, Is.Not.Null);
Assert.That(sceneContext.ObjectCount, Is.EqualTo(2));
Assert.That(sceneContext.Objects, Has.Count.EqualTo(2));
Assert.That(sceneContext.ActiveView, Is.EqualTo("None"));
Assert.That(sceneContext.Layers, Has.Count.EqualTo(2));
Assert.That(sceneContext.Layers, Contains.Item("Default"));
Assert.That(sceneContext.Layers, Contains.Item("TestLayer"));
}
[Test]
public void GetSceneContext_NullDoc_ThrowsArgumentNullException()
{
// Assert
Assert.Throws<ArgumentNullException>(() => RhinoUtilities.GetSceneContext(null));
}
}
/// <summary>
/// A dynamic proxy to help with RhinoDoc mocking
/// </summary>
public class MockDynamicRhinoDoc : System.Dynamic.DynamicObject
{
private readonly MockRhinoDoc _mockDoc;
private readonly MockRhinoViews _views = new MockRhinoViews();
public MockDynamicRhinoDoc(MockRhinoDoc mockDoc)
{
_mockDoc = mockDoc;
}
// Passthrough to mock object table
public MockRhinoObjectTable Objects => (MockRhinoObjectTable)_mockDoc.Objects;
// Passthrough to mock layer table
public MockLayerTable Layers => (MockLayerTable)_mockDoc.Layers;
// Provide a views collection
public MockRhinoViews Views => _views;
}
/// <summary>
/// A dynamic proxy to help with RhinoObject mocking
/// </summary>
public class MockDynamicRhinoObject : System.Dynamic.DynamicObject
{
private readonly IMockRhinoObject _mockObject;
private readonly MockRhinoDoc _mockDoc;
public MockDynamicRhinoObject(IMockRhinoObject mockObject, MockRhinoDoc mockDoc)
{
_mockObject = mockObject;
_mockDoc = mockDoc;
}
// Pass through for Id
public Guid Id => _mockObject.Id;
// Pass through for Attributes
public IMockObjectAttributes Attributes => _mockObject.Attributes;
// Pass through for Geometry
public GeometryBase Geometry => _mockObject.Geometry;
// Pass through for ObjectType
public ObjectType ObjectType => _mockObject.ObjectType;
// Provide document reference
public dynamic Document => new MockDynamicRhinoDoc(_mockDoc);
// Make sure we can serialize this object
public override string ToString() => $"MockRhinoObject:{Id}";
}
/// <summary>
/// Mock for Rhino views collection
/// </summary>
public class MockRhinoViews
{
private readonly MockRhinoView _activeView = new MockRhinoView();
public MockRhinoView ActiveView => _activeView;
}
/// <summary>
/// Mock for a Rhino view
/// </summary>
public class MockRhinoView
{
private readonly MockViewport _activeViewport = new MockViewport("Perspective");
public MockViewport ActiveViewport => _activeViewport;
}
/// <summary>
/// Mock for a Rhino viewport
/// </summary>
public class MockViewport
{
public string Name { get; }
public MockViewport(string name)
{
Name = name;
}
}
}
```
--------------------------------------------------------------------------------
/src/standalone-mcp-server.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Standalone MCP Server for Rhino
This script implements a direct MCP server that communicates with both Claude and Rhino
without any complex piping or shell scripts.
"""
import json
import os
import socket
import sys
import time
import logging
import signal
import threading
import traceback
from datetime import datetime
# Configure logging to stderr and file
log_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(log_dir)
log_file = os.path.join(root_dir, "logs", "standalone_mcp_server.log")
os.makedirs(os.path.dirname(log_file), exist_ok=True)
# Configure logging to stderr only
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
handlers=[
logging.StreamHandler(sys.stderr),
logging.FileHandler(log_file)
]
)
# Global variables
EXIT_FLAG = False
SERVER_STATE = "waiting" # States: waiting, initialized, processing
# Create a PID file
pid_file = os.path.join(root_dir, "logs", "standalone_server.pid")
with open(pid_file, "w") as f:
f.write(str(os.getpid()))
def send_json_response(data):
"""Send a JSON response to stdout (Claude)"""
try:
# Ensure we're sending a valid JSON object
json_str = json.dumps(data)
# Write as plain text to stdout
print(json_str, flush=True)
logging.debug(f"Sent JSON response: {json_str[:100]}...")
except Exception as e:
logging.error(f"Error sending JSON: {str(e)}")
def handle_initialize(request):
"""Handle the initialize request and return server capabilities"""
logging.info("Processing initialize request")
# Get request ID from the client (default to 0 if not provided)
request_id = request.get("id", 0)
# Return hard-coded initialization response with tools
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"serverInfo": {
"name": "RhinoMcpServer",
"version": "0.1.0"
},
"capabilities": {
"tools": [
{
"name": "geometry_tools.create_sphere",
"description": "Creates a sphere with the specified center and radius",
"parameters": [
{"name": "centerX", "description": "X coordinate of the sphere center", "required": True, "schema": {"type": "number"}},
{"name": "centerY", "description": "Y coordinate of the sphere center", "required": True, "schema": {"type": "number"}},
{"name": "centerZ", "description": "Z coordinate of the sphere center", "required": True, "schema": {"type": "number"}},
{"name": "radius", "description": "Radius of the sphere", "required": True, "schema": {"type": "number"}},
{"name": "color", "description": "Optional color for the sphere (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
},
{
"name": "geometry_tools.create_box",
"description": "Creates a box with the specified dimensions",
"parameters": [
{"name": "cornerX", "description": "X coordinate of the box corner", "required": True, "schema": {"type": "number"}},
{"name": "cornerY", "description": "Y coordinate of the box corner", "required": True, "schema": {"type": "number"}},
{"name": "cornerZ", "description": "Z coordinate of the box corner", "required": True, "schema": {"type": "number"}},
{"name": "width", "description": "Width of the box (X dimension)", "required": True, "schema": {"type": "number"}},
{"name": "depth", "description": "Depth of the box (Y dimension)", "required": True, "schema": {"type": "number"}},
{"name": "height", "description": "Height of the box (Z dimension)", "required": True, "schema": {"type": "number"}},
{"name": "color", "description": "Optional color for the box (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
},
{
"name": "geometry_tools.create_cylinder",
"description": "Creates a cylinder with the specified base point, height, and radius",
"parameters": [
{"name": "baseX", "description": "X coordinate of the cylinder base point", "required": True, "schema": {"type": "number"}},
{"name": "baseY", "description": "Y coordinate of the cylinder base point", "required": True, "schema": {"type": "number"}},
{"name": "baseZ", "description": "Z coordinate of the cylinder base point", "required": True, "schema": {"type": "number"}},
{"name": "height", "description": "Height of the cylinder", "required": True, "schema": {"type": "number"}},
{"name": "radius", "description": "Radius of the cylinder", "required": True, "schema": {"type": "number"}},
{"name": "color", "description": "Optional color for the cylinder (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
},
{
"name": "scene_tools.get_scene_info",
"description": "Gets information about objects in the current scene",
"parameters": []
},
{
"name": "scene_tools.clear_scene",
"description": "Clears all objects from the current scene",
"parameters": [
{"name": "currentLayerOnly", "description": "If true, only delete objects on the current layer", "required": False, "schema": {"type": "boolean"}}
]
},
{
"name": "scene_tools.create_layer",
"description": "Creates a new layer in the Rhino document",
"parameters": [
{"name": "name", "description": "Name of the new layer", "required": True, "schema": {"type": "string"}},
{"name": "color", "description": "Optional color for the layer (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
}
]
}
}
}
# Mark server as initialized
global SERVER_STATE
SERVER_STATE = "initialized"
logging.info("Server initialized successfully")
return response
def handle_tool_call(request):
"""Handle a tool call request"""
tool_name = request["params"]["name"]
parameters = request["params"]["parameters"]
request_id = request.get("id", 0)
logging.info(f"Executing tool: {tool_name}")
# Here you would actually implement the tool functionality
# For this example, we just return a dummy success response
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"result": {"success": True, "message": f"Executed {tool_name} with parameters {parameters}"}
}
}
return response
def handle_shutdown(request):
"""Handle a shutdown request"""
request_id = request.get("id", 0)
logging.info("Shutdown requested")
global EXIT_FLAG
EXIT_FLAG = True
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {"success": True}
}
return response
def process_message(message):
"""Process a single message from the client."""
try:
method = message.get("method", "")
if method == "initialize":
response = handle_initialize(message)
send_json_response(response)
return True
elif method == "tools/call":
response = handle_tool_call(message)
send_json_response(response)
return True
elif method == "shutdown":
response = handle_shutdown(message)
send_json_response(response)
return False
elif method == "notifications/cancelled":
# Just acknowledge and continue
logging.info("Received cancellation notification")
return True
else:
logging.warning(f"Unknown method: {method}")
return True
except Exception as e:
logging.error(f"Error processing message: {e}")
traceback.print_exc(file=sys.stderr)
return True # Keep running even on errors
def cleanup():
"""Clean up resources when exiting"""
try:
if os.path.exists(pid_file):
os.remove(pid_file)
logging.info("Removed PID file")
except Exception as e:
logging.error(f"Error in cleanup: {str(e)}")
def signal_handler(sig, frame):
"""Handle termination signals"""
global EXIT_FLAG, SERVER_STATE
logging.info(f"Received signal {sig}")
# If we're initialized, ignore SIGTERM (15) and stay alive
if SERVER_STATE == "initialized" and sig == signal.SIGTERM:
logging.info(f"Server is initialized - ignoring SIGTERM and staying alive")
return
# Otherwise, exit normally
logging.info(f"Initiating shutdown...")
EXIT_FLAG = True
def main():
"""Main function"""
global EXIT_FLAG
# Register signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# Register cleanup handler
import atexit
atexit.register(cleanup)
logging.info("=== MCP Server Starting ===")
logging.info(f"Process ID: {os.getpid()}")
try:
# Main loop
while not EXIT_FLAG:
try:
# Read a line from stdin
line = input()
if not line:
time.sleep(0.1)
continue
# Parse the JSON message
try:
message = json.loads(line)
# Process the message
if not process_message(message):
break
except json.JSONDecodeError as e:
logging.error(f"Invalid JSON received: {e}")
continue
except EOFError:
# If we're initialized, keep running even if stdin is closed
if SERVER_STATE == "initialized":
logging.info("Stdin closed but server is initialized - staying alive")
# Sleep to avoid tight loop if stdin is permanently closed
time.sleep(5)
continue
else:
logging.info("Stdin closed, exiting...")
break
except Exception as e:
logging.error(f"Error in main loop: {e}")
traceback.print_exc(file=sys.stderr)
time.sleep(1)
finally:
logging.info("Server shutting down...")
cleanup()
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/src/daemon_mcp_server.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Daemon MCP Server for Rhino
This script implements a socket-based MCP server that runs in the background
and allows multiple connections from Claude Desktop.
"""
import json
import os
import socket
import sys
import time
import logging
import signal
import threading
import traceback
from datetime import datetime
import socketserver
# Configure logging - log to both stderr and a file
log_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(log_dir)
log_file = os.path.join(root_dir, "logs", "daemon_mcp_server.log")
os.makedirs(os.path.dirname(log_file), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
handlers=[
logging.StreamHandler(sys.stderr),
logging.FileHandler(log_file)
]
)
# Global variables
EXIT_FLAG = False
SERVER_STATE = "waiting" # States: waiting, initialized, processing
SOCKET_PORT = 8765 # Port for the socket server
# Create a PID file to track this process
pid_file = os.path.join(root_dir, "logs", "daemon_server.pid")
with open(pid_file, "w") as f:
f.write(str(os.getpid()))
# Tools configuration - shared among all connections
SERVER_CAPABILITIES = {
"serverInfo": {
"name": "RhinoMcpServer",
"version": "0.1.0"
},
"capabilities": {
"tools": [
{
"name": "geometry_tools.create_sphere",
"description": "Creates a sphere with the specified center and radius",
"parameters": [
{"name": "centerX", "description": "X coordinate of the sphere center", "required": True, "schema": {"type": "number"}},
{"name": "centerY", "description": "Y coordinate of the sphere center", "required": True, "schema": {"type": "number"}},
{"name": "centerZ", "description": "Z coordinate of the sphere center", "required": True, "schema": {"type": "number"}},
{"name": "radius", "description": "Radius of the sphere", "required": True, "schema": {"type": "number"}},
{"name": "color", "description": "Optional color for the sphere (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
},
{
"name": "geometry_tools.create_box",
"description": "Creates a box with the specified dimensions",
"parameters": [
{"name": "cornerX", "description": "X coordinate of the box corner", "required": True, "schema": {"type": "number"}},
{"name": "cornerY", "description": "Y coordinate of the box corner", "required": True, "schema": {"type": "number"}},
{"name": "cornerZ", "description": "Z coordinate of the box corner", "required": True, "schema": {"type": "number"}},
{"name": "width", "description": "Width of the box (X dimension)", "required": True, "schema": {"type": "number"}},
{"name": "depth", "description": "Depth of the box (Y dimension)", "required": True, "schema": {"type": "number"}},
{"name": "height", "description": "Height of the box (Z dimension)", "required": True, "schema": {"type": "number"}},
{"name": "color", "description": "Optional color for the box (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
},
{
"name": "geometry_tools.create_cylinder",
"description": "Creates a cylinder with the specified base point, height, and radius",
"parameters": [
{"name": "baseX", "description": "X coordinate of the cylinder base point", "required": True, "schema": {"type": "number"}},
{"name": "baseY", "description": "Y coordinate of the cylinder base point", "required": True, "schema": {"type": "number"}},
{"name": "baseZ", "description": "Z coordinate of the cylinder base point", "required": True, "schema": {"type": "number"}},
{"name": "height", "description": "Height of the cylinder", "required": True, "schema": {"type": "number"}},
{"name": "radius", "description": "Radius of the cylinder", "required": True, "schema": {"type": "number"}},
{"name": "color", "description": "Optional color for the cylinder (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
},
{
"name": "scene_tools.get_scene_info",
"description": "Gets information about objects in the current scene",
"parameters": []
},
{
"name": "scene_tools.clear_scene",
"description": "Clears all objects from the current scene",
"parameters": [
{"name": "currentLayerOnly", "description": "If true, only delete objects on the current layer", "required": False, "schema": {"type": "boolean"}}
]
},
{
"name": "scene_tools.create_layer",
"description": "Creates a new layer in the Rhino document",
"parameters": [
{"name": "name", "description": "Name of the new layer", "required": True, "schema": {"type": "string"}},
{"name": "color", "description": "Optional color for the layer (e.g., 'red', 'blue', etc.)", "required": False, "schema": {"type": "string"}}
]
}
]
}
}
class MCPRequestHandler(socketserver.BaseRequestHandler):
"""
Handler for MCP requests over a TCP socket
"""
def handle(self):
"""Handle requests from a client"""
self.client_connected = True
self.local_state = "waiting"
logging.info(f"Client connected from {self.client_address}")
try:
buffer = b""
while not EXIT_FLAG and self.client_connected:
try:
# Read data from socket
data = self.request.recv(4096)
if not data:
# Client disconnected
logging.info(f"Client disconnected: {self.client_address}")
self.client_connected = False
break
# Add received data to buffer
buffer += data
# Process complete messages (assuming each message ends with newline)
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
if line:
line_str = line.decode('utf-8')
self.process_message(line_str)
except json.JSONDecodeError as e:
logging.error(f"Invalid JSON: {e}")
continue
except Exception as e:
logging.error(f"Error handling client: {e}")
traceback.print_exc()
# Don't break here - try to continue handling client
time.sleep(0.1)
continue
finally:
logging.info(f"Client handler exiting: {self.client_address}")
def process_message(self, message_str):
"""Process a message from the client"""
try:
message = json.loads(message_str)
method = message.get("method", "")
logging.info(f"Processing message: {method}")
if method == "initialize":
self.handle_initialize(message)
elif method == "tools/call":
self.handle_tool_call(message)
elif method == "shutdown":
self.handle_shutdown(message)
elif method == "notifications/cancelled":
logging.info("Received cancellation notification")
else:
logging.warning(f"Unknown method: {method}")
# Send error response for unknown methods
response = {
"jsonrpc": "2.0",
"id": message.get("id", 0),
"error": {
"code": -32601,
"message": f"Method '{method}' not found"
}
}
self.send_response(response)
except Exception as e:
logging.error(f"Error processing message: {e}")
traceback.print_exc()
# Try to send error response
try:
response = {
"jsonrpc": "2.0",
"id": message.get("id", 0) if isinstance(message, dict) else 0,
"error": {
"code": -32603,
"message": f"Internal error: {str(e)}"
}
}
self.send_response(response)
except:
pass
def handle_initialize(self, request):
"""Handle initialize request"""
global SERVER_STATE
request_id = request.get("id", 0)
# Set the global server state to initialized
SERVER_STATE = "initialized"
self.local_state = "initialized"
logging.info("Server initialized successfully")
# Create response
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": SERVER_CAPABILITIES
}
# Send response
self.send_response(response)
# Keep the connection open - don't close after initialization
logging.info("Initialization complete, keeping connection open for further requests")
def handle_tool_call(self, request):
"""Handle tool call request"""
tool_name = request["params"]["name"]
parameters = request["params"]["parameters"]
request_id = request.get("id", 0)
logging.info(f"Executing tool: {tool_name}")
# Return dummy success response
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {
"result": {"success": True, "message": f"Executed {tool_name} with parameters {parameters}"}
}
}
self.send_response(response)
def handle_shutdown(self, request):
"""Handle shutdown request"""
request_id = request.get("id", 0)
logging.info("Shutdown requested by client")
# Only shut down this client connection, not the entire server
response = {
"jsonrpc": "2.0",
"id": request_id,
"result": {"success": True}
}
self.send_response(response)
self.client_connected = False
def send_response(self, data):
"""Send JSON response to the client"""
try:
# Serialize to JSON and add newline
json_str = json.dumps(data) + "\n"
# Send as bytes
self.request.sendall(json_str.encode('utf-8'))
logging.debug(f"Sent response: {json_str[:100]}...")
except Exception as e:
logging.error(f"Error sending response: {e}")
traceback.print_exc()
# Don't close the connection on send error
logging.info("Continuing despite send error")
class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
"""Threaded TCP Server that allows for multiple simultaneous connections"""
daemon_threads = True
allow_reuse_address = True
def signal_handler(sig, frame):
"""Handle termination signals"""
global EXIT_FLAG
logging.info(f"Received signal {sig}")
# If server is in critical section, delay for a bit
if SERVER_STATE != "waiting":
logging.info("Server is busy, delaying shutdown...")
time.sleep(1)
# Set exit flag to trigger graceful shutdown
EXIT_FLAG = True
def cleanup():
"""Clean up resources when exiting"""
try:
if os.path.exists(pid_file):
os.remove(pid_file)
logging.info("Removed PID file")
except Exception as e:
logging.error(f"Error in cleanup: {e}")
def main():
"""Main function"""
global EXIT_FLAG
# Register signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# Register cleanup handler
import atexit
atexit.register(cleanup)
logging.info("=== Daemon MCP Server Starting ===")
logging.info(f"Process ID: {os.getpid()}")
logging.info(f"Listening on port {SOCKET_PORT}")
# Create the server
server = ThreadedTCPServer(('localhost', SOCKET_PORT), MCPRequestHandler)
# Start a thread with the server
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
try:
# Keep the main thread running
while not EXIT_FLAG:
time.sleep(0.1)
except KeyboardInterrupt:
logging.info("Keyboard interrupt received")
finally:
logging.info("Server shutting down...")
server.shutdown()
cleanup()
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/src/socket_proxy.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Socket Proxy for MCP Server
This script acts as a proxy between Claude Desktop and our daemon server.
It forwards stdin to the daemon server and stdout back to Claude Desktop.
"""
import json
import os
import socket
import sys
import time
import logging
import signal
import traceback
import subprocess
import threading
# Configure logging - to file only, NOT stdout or stderr
log_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(log_dir)
log_file = os.path.join(root_dir, "logs", "socket_proxy.log")
os.makedirs(os.path.dirname(log_file), exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(log_file)
]
)
# Global variables
SERVER_PORT = 8765 # Must match port in daemon_mcp_server.py
EXIT_FLAG = False
INITIALIZED = False # Flag to track if we've been initialized
def ensure_daemon_running():
"""Make sure the daemon server is running"""
daemon_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "daemon_mcp_server.py")
pid_file = os.path.join(root_dir, "logs", "daemon_server.pid")
# Check if PID file exists and process is running
if os.path.exists(pid_file):
try:
with open(pid_file, 'r') as f:
pid = int(f.read().strip())
# Try to send signal 0 to check if process exists
os.kill(pid, 0)
logging.info(f"Daemon server already running with PID {pid}")
return True
except (OSError, ValueError):
logging.info("Daemon server PID file exists but process is not running")
# Remove stale PID file
try:
os.remove(pid_file)
except:
pass
# Start the daemon server
logging.info("Starting daemon server...")
try:
# Make daemon script executable
os.chmod(daemon_path, 0o755)
# Start the daemon in the background
subprocess.Popen(
[daemon_path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
cwd=os.path.dirname(os.path.abspath(__file__))
)
# Wait for the daemon to start
for _ in range(10):
if os.path.exists(pid_file):
logging.info("Daemon server started successfully")
return True
time.sleep(0.5)
logging.error("Timeout waiting for daemon server to start")
return False
except Exception as e:
logging.error(f"Error starting daemon server: {e}")
return False
def connect_to_daemon():
"""Connect to the daemon server"""
for i in range(5): # Try 5 times with exponential backoff
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('localhost', SERVER_PORT))
logging.info("Connected to daemon server")
return sock
except (socket.error, ConnectionRefusedError) as e:
logging.warning(f"Connection attempt {i+1} failed: {e}")
time.sleep(2 ** i) # Exponential backoff
logging.error("Failed to connect to daemon server after multiple attempts")
return None
def forward_stdin_to_socket(sock):
"""Forward stdin to socket"""
global INITIALIZED
logging.info("Starting stdin forwarding thread")
try:
while not EXIT_FLAG:
try:
# Read a line from stdin
line = sys.stdin.readline()
if not line:
if INITIALIZED:
logging.info("Stdin closed but initialized - staying alive")
# Sleep longer to reduce log spam
time.sleep(30)
continue
else:
logging.info("Stdin closed")
break
# Check if this is an initialize message
try:
message = json.loads(line)
if message.get("method") == "initialize":
logging.info("Detected initialize message - will ignore termination signals")
INITIALIZED = True
except:
pass
# Forward to socket with newline termination
try:
sock.sendall(line.encode('utf-8'))
logging.debug(f"Forwarded to socket: {line.strip()}")
except socket.error as e:
logging.error(f"Socket error when forwarding stdin: {e}")
if INITIALIZED:
# If we're initialized, try to reconnect
logging.info("Trying to reconnect after socket error...")
try:
sock.close()
except:
pass
new_sock = connect_to_daemon()
if new_sock:
sock = new_sock
# Re-send the original message that failed
sock.sendall(line.encode('utf-8'))
logging.info("Reconnected and resent message")
else:
logging.error("Failed to reconnect after socket error")
if not INITIALIZED:
break
else:
break
except Exception as e:
logging.error(f"Error forwarding stdin: {e}")
if INITIALIZED:
# If we're initialized, just sleep and continue
time.sleep(5)
continue
else:
break
except Exception as e:
logging.error(f"Fatal error in stdin forwarding: {e}")
finally:
logging.info("Stdin forwarding thread exiting")
def forward_socket_to_stdout(sock):
"""Forward socket responses to stdout"""
global INITIALIZED # Move global declaration to beginning of function
logging.info("Starting socket forwarding thread")
buffer = b""
try:
while not EXIT_FLAG:
try:
# Read data from socket
data = sock.recv(4096)
if not data:
logging.info("Socket closed by server")
if INITIALIZED:
# Try to reconnect
logging.info("Trying to reconnect to daemon server...")
try:
sock.close()
except:
pass
new_sock = connect_to_daemon()
if new_sock:
sock = new_sock
sock.settimeout(2.0) # Make sure timeout is set on new socket
logging.info("Reconnected to daemon server")
continue
else:
# If reconnection fails but we're initialized, just sleep and retry
logging.info("Reconnection failed, but we're initialized - will retry later")
time.sleep(5)
continue
break
# Add to buffer
buffer += data
# Process complete messages (assuming each message ends with newline)
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
if line:
# Forward to stdout
line_str = line.decode('utf-8')
# Check if this is an initialization response before forwarding
try:
response = json.loads(line_str)
if isinstance(response, dict) and "jsonrpc" in response and "result" in response:
if "serverInfo" in response.get("result", {}):
logging.info("Detected initialization response - setting INITIALIZED flag")
INITIALIZED = True
except:
pass
# Always forward to stdout
print(line_str, flush=True)
logging.debug(f"Forwarded to stdout: {line_str}")
except socket.timeout:
# Just a timeout, not an error
continue
except Exception as e:
logging.error(f"Error reading from socket: {e}")
if INITIALIZED:
# If we're initialized, try to reconnect
try:
sock.close()
except:
pass
time.sleep(1)
new_sock = connect_to_daemon()
if new_sock:
sock = new_sock
sock.settimeout(2.0) # Make sure timeout is set on new socket
logging.info("Reconnected to daemon server after error")
continue
else:
# If reconnection fails but we're initialized, just sleep and retry
logging.info("Reconnection failed after error, but we're initialized - will retry later")
time.sleep(5)
continue
break
except Exception as e:
logging.error(f"Fatal error in socket forwarding: {e}")
finally:
logging.info("Socket forwarding thread exiting")
# If we're initialized, automatically restart the thread
if INITIALIZED and not EXIT_FLAG:
logging.info("Socket thread exited but we're initialized - restarting socket thread")
time.sleep(1) # Brief pause before reconnecting
new_sock = connect_to_daemon()
if new_sock:
new_sock.settimeout(2.0) # Make sure timeout is set on new socket
new_thread = threading.Thread(target=forward_socket_to_stdout, args=(new_sock,))
new_thread.daemon = True
new_thread.start()
def signal_handler(sig, frame):
"""Handle termination signals"""
global EXIT_FLAG
logging.info(f"Received signal {sig}")
# If initialized, ignore termination signals
if INITIALIZED:
logging.info(f"Ignoring signal {sig} after initialization")
return
# Otherwise, exit normally
logging.info(f"Exiting due to signal {sig}")
EXIT_FLAG = True
def main():
"""Main function"""
global EXIT_FLAG
# Register signal handlers
signal.signal(signal.SIGTERM, signal_handler)
signal.signal(signal.SIGINT, signal_handler)
# Create a PID file to indicate we're running
pid_file = os.path.join(root_dir, "logs", "socket_proxy.pid")
with open(pid_file, "w") as f:
f.write(str(os.getpid()))
logging.info("=== Socket Proxy Starting ===")
logging.info(f"Process ID: {os.getpid()}")
try:
# Make sure the daemon is running
if not ensure_daemon_running():
logging.error("Failed to start daemon server")
return 1
# Connect to the daemon
sock = connect_to_daemon()
if not sock:
logging.error("Failed to connect to daemon server")
return 1
# Set a timeout for the socket to prevent blocking indefinitely
sock.settimeout(2.0)
# Start forwarding threads
stdin_thread = threading.Thread(target=forward_stdin_to_socket, args=(sock,))
stdin_thread.daemon = True
stdin_thread.start()
socket_thread = threading.Thread(target=forward_socket_to_stdout, args=(sock,))
socket_thread.daemon = True
socket_thread.start()
# Stay alive forever once initialized
while not EXIT_FLAG:
if not stdin_thread.is_alive() and not socket_thread.is_alive():
if INITIALIZED:
logging.info("Both threads exited but we're initialized - restarting threads")
# Create a new socket and restart threads
new_sock = connect_to_daemon()
if new_sock:
sock = new_sock
stdin_thread = threading.Thread(target=forward_stdin_to_socket, args=(sock,))
stdin_thread.daemon = True
stdin_thread.start()
socket_thread = threading.Thread(target=forward_socket_to_stdout, args=(sock,))
socket_thread.daemon = True
socket_thread.start()
else:
# If reconnection fails, sleep and retry
time.sleep(10)
else:
logging.info("Both threads exited and not initialized - exiting")
break
time.sleep(0.1)
except KeyboardInterrupt:
logging.info("Keyboard interrupt received")
except Exception as e:
logging.error(f"Unexpected error in main thread: {e}")
traceback.print_exc(file=logging.FileHandler(log_file))
finally:
EXIT_FLAG = True
logging.info("Shutting down...")
try:
# Only remove PID file if not initialized
if not INITIALIZED and os.path.exists(pid_file):
os.remove(pid_file)
except:
pass
# If we're initialized, we'll stay alive forever
if INITIALIZED:
logging.info("Staying alive after initialization")
# Reset EXIT_FLAG since we want to continue running
EXIT_FLAG = False
# Enter a loop that attempts to reconnect to the daemon periodically
while True:
try:
if not os.path.exists(os.path.join(os.path.dirname(os.path.abspath(__file__)), "daemon_server.pid")):
logging.info("Daemon server PID file not found - attempting to restart daemon")
ensure_daemon_running()
# Check if we need to reconnect and restart threads
if not stdin_thread.is_alive() or not socket_thread.is_alive():
logging.info("One or more threads not running - attempting to restart")
new_sock = connect_to_daemon()
if new_sock:
sock = new_sock
if not stdin_thread.is_alive():
stdin_thread = threading.Thread(target=forward_stdin_to_socket, args=(sock,))
stdin_thread.daemon = True
stdin_thread.start()
if not socket_thread.is_alive():
socket_thread = threading.Thread(target=forward_socket_to_stdout, args=(sock,))
socket_thread.daemon = True
socket_thread.start()
time.sleep(10) # Check every 10 seconds
except Exception as e:
logging.error(f"Error in reconnection loop: {e}")
time.sleep(30) # Longer sleep on error
return 0
if __name__ == "__main__":
sys.exit(main())
```
--------------------------------------------------------------------------------
/log_manager.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Log Manager Utility for RhinoMcpServer
This script helps manage, view, and analyze logs from the MCP Server,
Rhino plugin, Claude AI, and diagnostic tools. It provides a unified
view of logs across all components to aid in debugging.
"""
import os
import sys
import re
import glob
import argparse
from datetime import datetime, timedelta
import json
import subprocess
# Define the log directory structure
LOG_ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
SERVER_LOGS = os.path.join(LOG_ROOT, "server")
PLUGIN_LOGS = os.path.join(LOG_ROOT, "plugin")
CLAUDE_LOGS = os.path.join(LOG_ROOT, "claude")
DIAGNOSTIC_LOGS = os.path.join(LOG_ROOT, "diagnostics")
# Log entry pattern for parsing - matches standard timestamp format
LOG_PATTERN = re.compile(r'^\[(?P<timestamp>.*?)\] \[(?P<level>.*?)\] \[(?P<component>.*?)\] (?P<message>.*)$')
class LogEntry:
"""Represents a parsed log entry with timestamp and metadata"""
def __init__(self, timestamp, level, component, message, source_file):
self.timestamp = timestamp
self.level = level.upper()
self.component = component
self.message = message
self.source_file = source_file
@classmethod
def from_line(cls, line, source_file):
"""Parse a log line into a LogEntry object"""
match = LOG_PATTERN.match(line)
if match:
try:
timestamp = datetime.strptime(match.group('timestamp'), '%Y-%m-%d %H:%M:%S,%f')
except ValueError:
try:
timestamp = datetime.strptime(match.group('timestamp'), '%Y-%m-%d %H:%M:%S')
except ValueError:
# If timestamp can't be parsed, use current time
timestamp = datetime.now()
return cls(
timestamp,
match.group('level'),
match.group('component'),
match.group('message'),
source_file
)
return None
def __lt__(self, other):
"""Support sorting by timestamp"""
return self.timestamp < other.timestamp
def to_string(self, colors=True, show_source=False):
"""Format the log entry for display"""
# Define ANSI color codes
color_map = {
"DEBUG": "\033[36m", # Cyan
"INFO": "\033[32m", # Green
"WARNING": "\033[33m", # Yellow
"ERROR": "\033[31m", # Red
"CRITICAL": "\033[41m\033[97m" # White on red background
}
component_colors = {
"server": "\033[94m", # Blue
"plugin": "\033[95m", # Magenta
"diagnostic": "\033[96m", # Cyan
"claude": "\033[92m" # Green
}
reset = "\033[0m"
# Format timestamp
timestamp_str = self.timestamp.strftime("%Y-%m-%d %H:%M:%S")
# Apply colors if enabled
if colors:
level_color = color_map.get(self.level, "")
comp_color = component_colors.get(self.component, "")
if self.level in ["ERROR", "CRITICAL"]:
# For errors, color the whole line
source_info = f" ({os.path.basename(self.source_file)})" if show_source else ""
return f"{level_color}[{timestamp_str}] [{self.level}] [{self.component}] {self.message}{source_info}{reset}"
else:
# For non-errors, color just the level and component
source_info = f" ({os.path.basename(self.source_file)})" if show_source else ""
return f"[{timestamp_str}] [{level_color}{self.level}{reset}] [{comp_color}{self.component}{reset}] {self.message}{source_info}"
else:
source_info = f" ({os.path.basename(self.source_file)})" if show_source else ""
return f"[{timestamp_str}] [{self.level}] [{self.component}] {self.message}{source_info}"
def collect_logs(since=None, level_filter=None, component_filter=None):
"""Collect and parse logs from all sources
Args:
since: Datetime object for filtering logs by age
level_filter: List of log levels to include (DEBUG, INFO, etc)
component_filter: List of components to include (server, plugin, etc)
Returns:
List of LogEntry objects sorted by timestamp
"""
all_entries = []
# Create directories if they don't exist
for directory in [LOG_ROOT, SERVER_LOGS, PLUGIN_LOGS, CLAUDE_LOGS, DIAGNOSTIC_LOGS]:
os.makedirs(directory, exist_ok=True)
# Gather log files from all directories
log_files = []
log_files.extend(glob.glob(os.path.join(SERVER_LOGS, "*.log")))
log_files.extend(glob.glob(os.path.join(PLUGIN_LOGS, "*.log")))
log_files.extend(glob.glob(os.path.join(DIAGNOSTIC_LOGS, "*.log")))
# Also check for Claude logs but handle them differently
claude_files = glob.glob(os.path.join(CLAUDE_LOGS, "*.log"))
# Process standard log files
for log_file in log_files:
try:
with open(log_file, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
entry = LogEntry.from_line(line, log_file)
if entry:
# Apply filters
if since and entry.timestamp < since:
continue
if level_filter and entry.level not in level_filter:
continue
if component_filter and entry.component not in component_filter:
continue
all_entries.append(entry)
except Exception as e:
print(f"Error processing {log_file}: {e}", file=sys.stderr)
# Handle Claude logs which have a different format
for claude_file in claude_files:
try:
# Get file creation time to use as timestamp
file_time = datetime.fromtimestamp(os.path.getctime(claude_file))
# Skip if it's before our filter time
if since and file_time < since:
continue
# Only include preview of Claude logs since they can be large
with open(claude_file, 'r') as f:
content = f.read(500) # Just read first 500 chars
truncated = len(content) < os.path.getsize(claude_file)
message = content + ("..." if truncated else "")
# Create a synthetic log entry
entry = LogEntry(
file_time,
"INFO",
"claude",
f"Claude interaction: {message}",
claude_file
)
# Apply filters
if not component_filter or "claude" in component_filter:
all_entries.append(entry)
except Exception as e:
print(f"Error processing Claude log {claude_file}: {e}", file=sys.stderr)
# Sort all entries by timestamp
all_entries.sort()
return all_entries
def display_logs(entries, colors=True, show_source=False, max_entries=None):
"""Display log entries with optional formatting
Args:
entries: List of LogEntry objects
colors: Whether to use ANSI colors in output
show_source: Whether to show source filename
max_entries: Maximum number of entries to show (None for all)
"""
# Maybe limit entries
if max_entries is not None and len(entries) > max_entries:
skipped = len(entries) - max_entries
entries = entries[-max_entries:]
print(f"... (skipped {skipped} earlier entries) ...\n")
for entry in entries:
print(entry.to_string(colors=colors, show_source=show_source))
def extract_error_context(entries, context_lines=5):
"""Extract log entries around errors with context
Args:
entries: List of all LogEntry objects
context_lines: Number of log lines before and after each error
Returns:
List of error contexts (each being a list of LogEntry objects)
"""
error_contexts = []
error_indices = [i for i, entry in enumerate(entries) if entry.level in ["ERROR", "CRITICAL"]]
for error_idx in error_indices:
# Get context before and after error
start_idx = max(0, error_idx - context_lines)
end_idx = min(len(entries), error_idx + context_lines + 1)
# Extract the context
context = entries[start_idx:end_idx]
error_contexts.append(context)
return error_contexts
def generate_error_report(entries):
"""Generate a summary report of errors
Args:
entries: List of LogEntry objects
Returns:
String containing the error report
"""
error_entries = [e for e in entries if e.level in ["ERROR", "CRITICAL"]]
if not error_entries:
return "No errors found in logs."
# Group errors by component
errors_by_component = {}
for entry in error_entries:
if entry.component not in errors_by_component:
errors_by_component[entry.component] = []
errors_by_component[entry.component].append(entry)
# Generate the report
report = f"Error Report ({len(error_entries)} errors found)\n"
report += "=" * 50 + "\n\n"
for component, errors in errors_by_component.items():
report += f"{component.upper()} Errors: {len(errors)}\n"
report += "-" * 30 + "\n"
# Group by error message pattern
error_patterns = {}
for error in errors:
# Simplify message by removing variable parts (numbers, IDs, etc)
simplified = re.sub(r'\b(?:\w+[-_])?[0-9a-f]{8}(?:[-_]\w+)?\b', 'ID', error.message)
simplified = re.sub(r'\d+', 'N', simplified)
if simplified not in error_patterns:
error_patterns[simplified] = []
error_patterns[simplified].append(error)
# Report each error pattern
for pattern, pattern_errors in error_patterns.items():
report += f"\n• {pattern} ({len(pattern_errors)} occurrences)\n"
report += f" First seen: {pattern_errors[0].timestamp}\n"
report += f" Last seen: {pattern_errors[-1].timestamp}\n"
report += f" Example: {pattern_errors[-1].message}\n"
report += "\n" + "=" * 50 + "\n\n"
return report
def clear_logs(days_old=None, component=None, confirm=True):
"""Clear logs based on specified criteria
Args:
days_old: Delete logs older than this many days
component: Only delete logs from this component
confirm: Whether to prompt for confirmation
"""
# Determine which directories to clean
dirs_to_clean = []
if component == "server" or component is None:
dirs_to_clean.append(SERVER_LOGS)
if component == "plugin" or component is None:
dirs_to_clean.append(PLUGIN_LOGS)
if component == "claude" or component is None:
dirs_to_clean.append(CLAUDE_LOGS)
if component == "diagnostic" or component is None:
dirs_to_clean.append(DIAGNOSTIC_LOGS)
# Collect files to delete
files_to_delete = []
for directory in dirs_to_clean:
for log_file in glob.glob(os.path.join(directory, "*.log")):
if days_old is not None:
# Check file age
file_time = datetime.fromtimestamp(os.path.getmtime(log_file))
cutoff_time = datetime.now() - timedelta(days=days_old)
if file_time >= cutoff_time:
continue # Skip files newer than cutoff
files_to_delete.append(log_file)
# Nothing to delete
if not files_to_delete:
print("No logs found matching the specified criteria.")
return
# Confirm deletion
if confirm:
print(f"Will delete {len(files_to_delete)} log files:")
for f in files_to_delete[:5]:
print(f" - {os.path.basename(f)}")
if len(files_to_delete) > 5:
print(f" - ... and {len(files_to_delete) - 5} more")
confirmation = input("Proceed with deletion? (y/N): ").lower()
if confirmation != 'y':
print("Deletion cancelled.")
return
# Delete the files
deleted_count = 0
for log_file in files_to_delete:
try:
os.remove(log_file)
deleted_count += 1
except Exception as e:
print(f"Error deleting {log_file}: {e}")
print(f"Successfully deleted {deleted_count} log files.")
def monitor_logs(interval=1.0, colors=True, level_filter=None, component_filter=None):
"""Monitor logs in real-time like 'tail -f'
Args:
interval: Polling interval in seconds
colors: Whether to use ANSI colors
level_filter: Optional list of log levels to show
component_filter: Optional list of components to show
"""
print(f"Monitoring logs (Ctrl+C to exit)...")
print(f"Filters: levels={level_filter or 'all'}, components={component_filter or 'all'}")
# Get initial log entries and remember the latest timestamp
entries = collect_logs(level_filter=level_filter, component_filter=component_filter)
last_timestamp = entries[-1].timestamp if entries else datetime.now()
try:
while True:
# Wait for the specified interval
sys.stdout.flush()
subprocess.call("", shell=True) # Hack to make ANSI colors work in Windows
# Get new entries since the last check
new_entries = collect_logs(
since=last_timestamp,
level_filter=level_filter,
component_filter=component_filter
)
# Update timestamp for the next iteration
if new_entries:
last_timestamp = new_entries[-1].timestamp
# Display new entries
for entry in new_entries:
print(entry.to_string(colors=colors, show_source=True))
# Sleep before checking again
import time
time.sleep(interval)
except KeyboardInterrupt:
print("\nStopped monitoring logs.")
def main():
"""Parse arguments and execute requested command"""
parser = argparse.ArgumentParser(description="Manage and view RhinoMcpServer logs")
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
# view command
view_parser = subparsers.add_parser("view", help="View logs")
view_parser.add_argument("--since", type=str, help="Show logs since (e.g. '1h', '2d', '30m')")
view_parser.add_argument("--level", type=str, help="Filter by log level (comma-separated: DEBUG,INFO,WARNING,ERROR)")
view_parser.add_argument("--component", type=str, help="Filter by component (comma-separated: server,plugin,claude,diagnostic)")
view_parser.add_argument("--no-color", action="store_true", help="Disable colored output")
view_parser.add_argument("--source", action="store_true", help="Show source log file")
view_parser.add_argument("--max", type=int, help="Maximum number of entries to display")
# errors command
errors_parser = subparsers.add_parser("errors", help="View errors with context")
errors_parser.add_argument("--since", type=str, help="Show errors since (e.g. '1h', '2d', '30m')")
errors_parser.add_argument("--component", type=str, help="Filter by component")
errors_parser.add_argument("--context", type=int, default=5, help="Number of context lines before/after each error")
errors_parser.add_argument("--no-color", action="store_true", help="Disable colored output")
# report command
report_parser = subparsers.add_parser("report", help="Generate error report")
report_parser.add_argument("--since", type=str, help="Include errors since (e.g. '1h', '2d', '30m')")
report_parser.add_argument("--output", type=str, help="Output file for the report")
# clear command
clear_parser = subparsers.add_parser("clear", help="Clear logs")
clear_parser.add_argument("--older-than", type=int, help="Delete logs older than N days")
clear_parser.add_argument("--component", type=str, help="Only clear logs for the specified component")
clear_parser.add_argument("--force", action="store_true", help="Do not ask for confirmation")
# monitor command
monitor_parser = subparsers.add_parser("monitor", help="Monitor logs in real-time")
monitor_parser.add_argument("--interval", type=float, default=1.0, help="Polling interval in seconds")
monitor_parser.add_argument("--level", type=str, help="Filter by log level (comma-separated)")
monitor_parser.add_argument("--component", type=str, help="Filter by component (comma-separated)")
monitor_parser.add_argument("--no-color", action="store_true", help="Disable colored output")
# info command
info_parser = subparsers.add_parser("info", help="Show information about logs")
args = parser.parse_args()
# Handle time expressions like "1h", "2d", etc.
since_time = None
if hasattr(args, 'since') and args.since:
try:
# Parse time expressions
value = int(args.since[:-1])
unit = args.since[-1].lower()
if unit == 'h':
since_time = datetime.now() - timedelta(hours=value)
elif unit == 'm':
since_time = datetime.now() - timedelta(minutes=value)
elif unit == 'd':
since_time = datetime.now() - timedelta(days=value)
else:
print(f"Invalid time unit in '{args.since}'. Use 'm' for minutes, 'h' for hours, 'd' for days.")
return 1
except ValueError:
print(f"Invalid time format: '{args.since}'. Use e.g. '1h', '30m', '2d'")
return 1
# Parse level and component filters
level_filter = None
if hasattr(args, 'level') and args.level:
level_filter = [l.strip().upper() for l in args.level.split(',')]
component_filter = None
if hasattr(args, 'component') and args.component:
component_filter = [c.strip().lower() for c in args.component.split(',')]
# Execute the requested command
if args.command == "view":
entries = collect_logs(since=since_time, level_filter=level_filter, component_filter=component_filter)
if not entries:
print("No log entries found matching the criteria.")
return 0
display_logs(
entries,
colors=not args.no_color,
show_source=args.source,
max_entries=args.max
)
elif args.command == "errors":
# Collect all entries first
all_entries = collect_logs(since=since_time, component_filter=component_filter)
if not all_entries:
print("No log entries found.")
return 0
# Extract errors with context
error_contexts = extract_error_context(all_entries, context_lines=args.context)
if not error_contexts:
print("No errors found in the logs.")
return 0
# Display each error with its context
for i, context in enumerate(error_contexts):
if i > 0:
print("\n" + "-" * 80 + "\n")
print(f"Error {i+1} of {len(error_contexts)}:")
for entry in context:
print(entry.to_string(colors=not args.no_color, show_source=True))
elif args.command == "report":
entries = collect_logs(since=since_time)
if not entries:
print("No log entries found.")
return 0
report = generate_error_report(entries)
if args.output:
with open(args.output, 'w') as f:
f.write(report)
print(f"Error report saved to {args.output}")
else:
print(report)
elif args.command == "clear":
clear_logs(
days_old=args.older_than,
component=args.component,
confirm=not args.force
)
elif args.command == "monitor":
monitor_logs(
interval=args.interval,
colors=not args.no_color,
level_filter=level_filter,
component_filter=component_filter
)
elif args.command == "info":
# Show information about available logs
print("Log Directory Structure:")
print(f" Root: {LOG_ROOT}")
# Helper function to summarize logs in a directory
def summarize_dir(dir_path, name):
if not os.path.exists(dir_path):
return f"{name}: Directory not found"
log_files = glob.glob(os.path.join(dir_path, "*.log"))
if not log_files:
return f"{name}: No log files found"
newest = max(log_files, key=os.path.getmtime)
oldest = min(log_files, key=os.path.getmtime)
newest_time = datetime.fromtimestamp(os.path.getmtime(newest))
oldest_time = datetime.fromtimestamp(os.path.getmtime(oldest))
total_size = sum(os.path.getsize(f) for f in log_files)
size_mb = total_size / (1024 * 1024)
return (f"{name}: {len(log_files)} files, {size_mb:.2f} MB total\n"
f" Newest: {os.path.basename(newest)} ({newest_time})\n"
f" Oldest: {os.path.basename(oldest)} ({oldest_time})")
print("\nLog Summaries:")
print(summarize_dir(SERVER_LOGS, "Server Logs"))
print(summarize_dir(PLUGIN_LOGS, "Plugin Logs"))
print(summarize_dir(CLAUDE_LOGS, "Claude Logs"))
print(summarize_dir(DIAGNOSTIC_LOGS, "Diagnostic Logs"))
else:
parser.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())
```
--------------------------------------------------------------------------------
/combined_mcp_server.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Combined MCP Server for Rhino
This script implements a direct MCP server using the FastMCP pattern,
following the Model Context Protocol specification.
"""
import json
import os
import sys
import time
import logging
import signal
import threading
import traceback
from datetime import datetime
import re
import uuid
import asyncio
import socket
from typing import Dict, Any, List, Optional, Tuple
from contextlib import asynccontextmanager
from typing import AsyncIterator
# Import the FastMCP class
from mcp.server.fastmcp import FastMCP, Context
# Configure logging - improved with structured format and unified location
log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs")
os.makedirs(log_dir, exist_ok=True)
# Create log subdirectories
server_log_dir = os.path.join(log_dir, "server")
plugin_log_dir = os.path.join(log_dir, "plugin")
claude_log_dir = os.path.join(log_dir, "claude")
for directory in [server_log_dir, plugin_log_dir, claude_log_dir]:
os.makedirs(directory, exist_ok=True)
# Log filenames based on date for easier archiving
today = datetime.now().strftime("%Y-%m-%d")
server_log_file = os.path.join(server_log_dir, f"server_{today}.log")
debug_log_file = os.path.join(server_log_dir, f"debug_{today}.log")
# Set up the logger with custom format including timestamp, level, component, and message
logging.basicConfig(
level=logging.INFO,
format='[%(asctime)s] [%(levelname)s] [%(component)s] %(message)s',
handlers=[
logging.StreamHandler(sys.stderr),
logging.FileHandler(server_log_file)
]
)
# Add a filter to add the component field
class ComponentFilter(logging.Filter):
def __init__(self, component="server"):
super().__init__()
self.component = component
def filter(self, record):
record.component = self.component
return True
# Get logger and add the component filter
logger = logging.getLogger()
logger.addFilter(ComponentFilter())
# Add a debug file handler for detailed debugging
debug_handler = logging.FileHandler(debug_log_file)
debug_handler.setLevel(logging.DEBUG)
debug_handler.setFormatter(logging.Formatter('[%(asctime)s] [%(levelname)s] [%(component)s] %(message)s'))
logger.addHandler(debug_handler)
# Log basic server startup information
logger.info(f"RhinoMCP server starting in {os.getcwd()}")
logger.info(f"Log files directory: {log_dir}")
logger.info(f"Server log file: {server_log_file}")
logger.info(f"Debug log file: {debug_log_file}")
# Create a PID file to track this process
pid_file = os.path.join(log_dir, "combined_server.pid")
with open(pid_file, "w") as f:
f.write(str(os.getpid()))
logger.info(f"Server PID: {os.getpid()}")
# Global Rhino connection
_rhino_connection = None
class RhinoConnection:
"""Class to manage socket connection to Rhino plugin"""
def __init__(self, host: str = "localhost", port: int = 9876):
self.host = host
self.port = port
self.sock = None
self.request_id = 0
def connect(self) -> bool:
"""Connect to the Rhino plugin socket server"""
if self.sock:
return True
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
logger.info(f"Connected to Rhino at {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Rhino: {str(e)}")
logger.debug(f"Connection error details: {traceback.format_exc()}")
self.sock = None
return False
def disconnect(self):
"""Disconnect from the Rhino plugin"""
if self.sock:
try:
self.sock.close()
except Exception as e:
logger.error(f"Error disconnecting from Rhino: {str(e)}")
finally:
self.sock = None
def send_command(self, command_type: str, params: Dict[str, Any] = None) -> Dict[str, Any]:
"""Send a command to Rhino and return the response"""
if not self.sock and not self.connect():
raise ConnectionError("Not connected to Rhino")
# Increment request ID for tracking
self.request_id += 1
current_request_id = self.request_id
command = {
"id": current_request_id,
"type": command_type,
"params": params or {}
}
try:
# Log the command being sent
logger.info(f"Request #{current_request_id}: Sending command '{command_type}' to Rhino")
logger.debug(f"Request #{current_request_id} Parameters: {json.dumps(params or {})}")
# Send the command
command_json = json.dumps(command)
logger.debug(f"Request #{current_request_id} Raw command: {command_json}")
self.sock.sendall(command_json.encode('utf-8'))
# Set a timeout for receiving
self.sock.settimeout(10.0)
# Receive the response
buffer_size = 4096
response_data = b""
while True:
chunk = self.sock.recv(buffer_size)
if not chunk:
break
response_data += chunk
# Try to parse as JSON to see if we have a complete response
try:
json.loads(response_data.decode('utf-8'))
# If parsing succeeds, we have a complete response
break
except json.JSONDecodeError:
# Not a complete JSON yet, continue receiving
continue
if not response_data:
logger.error(f"Request #{current_request_id}: No data received from Rhino")
raise ConnectionError(f"Request #{current_request_id}: No data received from Rhino")
# Log the raw response for debugging
raw_response = response_data.decode('utf-8')
logger.debug(f"Request #{current_request_id} Raw response: {raw_response}")
response = json.loads(raw_response)
# Check if the response indicates an error
if "error" in response:
error_msg = response.get("error", "Unknown error from Rhino")
logger.error(f"Request #{current_request_id}: Rhino reported error: {error_msg}")
raise Exception(f"Request #{current_request_id}: Rhino error: {error_msg}")
if response.get("status") == "error":
error_msg = response.get("message", "Unknown error from Rhino")
logger.error(f"Request #{current_request_id}: Rhino reported error status: {error_msg}")
raise Exception(f"Request #{current_request_id}: Rhino error: {error_msg}")
# Log success
logger.info(f"Request #{current_request_id}: Command '{command_type}' executed successfully")
# If we get here, assume success and return the result
if "result" in response:
return response.get("result", {})
else:
# If there's no result field but no error either, return the whole response
return response
except socket.timeout:
logger.error(f"Request #{current_request_id}: Socket timeout while waiting for response from Rhino")
logger.debug(f"Request #{current_request_id}: Timeout after 10 seconds waiting for response to '{command_type}'")
self.sock = None
raise Exception(f"Request #{current_request_id}: Timeout waiting for Rhino response")
except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
logger.error(f"Request #{current_request_id}: Socket connection error: {str(e)}")
self.sock = None
raise Exception(f"Request #{current_request_id}: Connection to Rhino lost: {str(e)}")
except json.JSONDecodeError as e:
logger.error(f"Request #{current_request_id}: Invalid JSON response: {str(e)}")
if 'response_data' in locals():
logger.error(f"Request #{current_request_id}: Raw response causing JSON error: {response_data[:200]}")
self.sock = None
raise Exception(f"Request #{current_request_id}: Invalid JSON response from Rhino: {str(e)}")
except Exception as e:
logger.error(f"Request #{current_request_id}: Error communicating with Rhino: {str(e)}")
logger.error(f"Request #{current_request_id}: Traceback: {traceback.format_exc()}")
self.sock = None
raise Exception(f"Request #{current_request_id}: Communication error with Rhino: {str(e)}")
def get_rhino_connection() -> RhinoConnection:
"""Get or create a connection to Rhino"""
global _rhino_connection
# If we have an existing connection, check if it's still valid
if _rhino_connection is not None:
try:
# Don't use ping as it's not implemented in the Rhino plugin
# Instead, try get_scene_info which is more likely to work
_rhino_connection.send_command("get_scene_info", {})
return _rhino_connection
except Exception as e:
# Connection is dead, close it and create a new one
logger.warning(f"Existing connection is no longer valid: {str(e)}")
try:
_rhino_connection.disconnect()
except:
pass
_rhino_connection = None
# Create a new connection if needed
if _rhino_connection is None:
_rhino_connection = RhinoConnection()
if not _rhino_connection.connect():
logger.error("Failed to connect to Rhino")
_rhino_connection = None
raise Exception("Could not connect to Rhino. Make sure the Rhino plugin is running.")
# Verify connection with a known working command
try:
# Test the connection with get_scene_info command
result = _rhino_connection.send_command("get_scene_info", {})
logger.info(f"Connection test successful: {result}")
except Exception as e:
logger.error(f"Connection test failed: {str(e)}")
_rhino_connection.disconnect()
_rhino_connection = None
raise Exception(f"Rhino plugin connection test failed: {str(e)}")
logger.info("Created new connection to Rhino")
return _rhino_connection
# Server lifecycle management
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"""Manage server startup and shutdown lifecycle"""
try:
logger.info("RhinoMCP server starting up")
# Try to connect to Rhino on startup to verify it's available
try:
# This will initialize the global connection if needed
rhino = get_rhino_connection()
logger.info("Successfully connected to Rhino on startup")
except Exception as e:
logger.warning(f"Could not connect to Rhino on startup: {str(e)}")
logger.warning("Make sure the Rhino plugin is running before using Rhino resources or tools")
yield {} # No context resources needed for now
finally:
logger.info("RhinoMCP server shutting down")
# Cleanup code
global _rhino_connection
if _rhino_connection:
logger.info("Disconnecting from Rhino on shutdown")
_rhino_connection.disconnect()
_rhino_connection = None
if os.path.exists(pid_file):
os.remove(pid_file)
logger.info("Removed PID file")
# Initialize the FastMCP server
mcp = FastMCP(
"RhinoMcpServer",
description="A Model Context Protocol server for Rhino 3D",
lifespan=server_lifespan
)
# Tool implementations using FastMCP decorators
@mcp.tool()
def create_sphere(
ctx: Context,
centerX: float,
centerY: float,
centerZ: float,
radius: float,
color: Optional[str] = None
) -> str:
"""
Creates a sphere with the specified center and radius.
Parameters:
- centerX: X coordinate of the sphere center
- centerY: Y coordinate of the sphere center
- centerZ: Z coordinate of the sphere center
- radius: Radius of the sphere
- color: Optional color for the sphere (e.g., 'red', 'blue', etc.)
Returns:
A message indicating the created sphere details
"""
tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
logger.info(f"[{tool_id}] Tool call: create_sphere with center=({centerX},{centerY},{centerZ}), radius={radius}, color={color}")
try:
# Get the Rhino connection
rhino = get_rhino_connection()
# Send the command to Rhino
params = {
"centerX": centerX,
"centerY": centerY,
"centerZ": centerZ,
"radius": radius
}
if color:
params["color"] = color
result = rhino.send_command("create_sphere", params)
# Log success
logger.info(f"[{tool_id}] Sphere created successfully")
# Return the result
return json.dumps(result)
except Exception as e:
logger.error(f"[{tool_id}] Error creating sphere: {str(e)}")
logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
return json.dumps({
"success": False,
"error": f"Error creating sphere: {str(e)}"
})
@mcp.tool()
def create_box(
ctx: Context,
cornerX: float,
cornerY: float,
cornerZ: float,
width: float,
depth: float,
height: float,
color: Optional[str] = None
) -> str:
"""
Creates a box with the specified dimensions.
Parameters:
- cornerX: X coordinate of the box corner
- cornerY: Y coordinate of the box corner
- cornerZ: Z coordinate of the box corner
- width: Width of the box (X dimension)
- depth: Depth of the box (Y dimension)
- height: Height of the box (Z dimension)
- color: Optional color for the box (e.g., 'red', 'blue', etc.)
Returns:
A message indicating the created box details
"""
tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
logger.info(f"[{tool_id}] Tool call: create_box at ({cornerX},{cornerY},{cornerZ}), size={width}x{depth}x{height}, color={color}")
try:
# Get the Rhino connection
rhino = get_rhino_connection()
# Send the command to Rhino
params = {
"cornerX": cornerX,
"cornerY": cornerY,
"cornerZ": cornerZ,
"width": width,
"depth": depth,
"height": height
}
if color:
params["color"] = color
result = rhino.send_command("create_box", params)
# Log success
logger.info(f"[{tool_id}] Box created successfully")
# Return the result
return json.dumps(result)
except Exception as e:
logger.error(f"[{tool_id}] Error creating box: {str(e)}")
logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
return json.dumps({
"success": False,
"error": f"Error creating box: {str(e)}",
"toolId": tool_id # Include the tool ID for error tracking
})
@mcp.tool()
def create_cylinder(
ctx: Context,
baseX: float,
baseY: float,
baseZ: float,
height: float,
radius: float,
color: Optional[str] = None
) -> str:
"""
Creates a cylinder with the specified base point, height, and radius.
Parameters:
- baseX: X coordinate of the cylinder base point
- baseY: Y coordinate of the cylinder base point
- baseZ: Z coordinate of the cylinder base point
- height: Height of the cylinder
- radius: Radius of the cylinder
- color: Optional color for the cylinder (e.g., 'red', 'blue', etc.)
Returns:
A message indicating the created cylinder details
"""
tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
logger.info(f"[{tool_id}] Tool call: create_cylinder at ({baseX},{baseY},{baseZ}), height={height}, radius={radius}, color={color}")
try:
# Get the Rhino connection
rhino = get_rhino_connection()
# Send the command to Rhino
params = {
"baseX": baseX,
"baseY": baseY,
"baseZ": baseZ,
"height": height,
"radius": radius
}
if color:
params["color"] = color
result = rhino.send_command("create_cylinder", params)
# Log success
logger.info(f"[{tool_id}] Cylinder created successfully")
# Return the result
return json.dumps(result)
except Exception as e:
logger.error(f"[{tool_id}] Error creating cylinder: {str(e)}")
logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
return json.dumps({
"success": False,
"error": f"Error creating cylinder: {str(e)}",
"toolId": tool_id # Include the tool ID for error tracking
})
@mcp.tool()
def get_scene_info(ctx: Context) -> str:
"""
Gets information about objects in the current scene.
Returns:
A JSON string containing scene information
"""
tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
logger.info(f"[{tool_id}] Tool call: get_scene_info")
try:
# Get the Rhino connection
rhino = get_rhino_connection()
# Send the command to Rhino
result = rhino.send_command("get_scene_info", {})
# Log success
logger.info(f"[{tool_id}] Scene info retrieved successfully")
# Return the result
return json.dumps(result)
except Exception as e:
logger.error(f"[{tool_id}] Error getting scene info: {str(e)}")
logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
return json.dumps({
"success": False,
"error": f"Error getting scene info: {str(e)}",
"toolId": tool_id # Include the tool ID for error tracking
})
@mcp.tool()
def clear_scene(ctx: Context, currentLayerOnly: bool = False) -> str:
"""
Clears all objects from the current scene.
Parameters:
- currentLayerOnly: If true, only delete objects on the current layer
Returns:
A message indicating the operation result
"""
tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
layer_info = "current layer only" if currentLayerOnly else "all layers"
logger.info(f"[{tool_id}] Tool call: clear_scene ({layer_info})")
try:
# Get the Rhino connection
rhino = get_rhino_connection()
# Send the command to Rhino
params = {
"currentLayerOnly": currentLayerOnly
}
result = rhino.send_command("clear_scene", params)
# Log success
logger.info(f"[{tool_id}] Scene cleared successfully ({layer_info})")
# Return the result
return json.dumps(result)
except Exception as e:
logger.error(f"[{tool_id}] Error clearing scene: {str(e)}")
logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
return json.dumps({
"success": False,
"error": f"Error clearing scene: {str(e)}",
"toolId": tool_id # Include the tool ID for error tracking
})
@mcp.tool()
def create_layer(ctx: Context, name: str, color: Optional[str] = None) -> str:
"""
Creates a new layer in the Rhino document.
Parameters:
- name: Name of the new layer
- color: Optional color for the layer (e.g., 'red', 'blue', etc.)
Returns:
A message indicating the operation result
"""
tool_id = str(uuid.uuid4())[:8] # Generate a short ID for tracking this tool call
color_info = f" with color {color}" if color else ""
logger.info(f"[{tool_id}] Tool call: create_layer '{name}'{color_info}")
try:
# Get the Rhino connection
rhino = get_rhino_connection()
# Send the command to Rhino
params = {
"name": name
}
if color:
params["color"] = color
result = rhino.send_command("create_layer", params)
# Log success
logger.info(f"[{tool_id}] Layer '{name}' created successfully")
# Return the result
return json.dumps(result)
except Exception as e:
logger.error(f"[{tool_id}] Error creating layer: {str(e)}")
logger.error(f"[{tool_id}] Traceback: {traceback.format_exc()}")
return json.dumps({
"success": False,
"error": f"Error creating layer: {str(e)}",
"toolId": tool_id # Include the tool ID for error tracking
})
# Record Claude interactions to debug context issues
@mcp.tool()
def log_claude_message(
ctx: Context,
message: str,
type: str = "info"
) -> str:
"""
Log a message from Claude for debugging purposes.
Parameters:
- message: The message to log
- type: The type of message (info, error, warning, debug)
Returns:
Success confirmation
"""
log_id = str(uuid.uuid4())[:8]
# Create a timestamp for the filename
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
claude_log_file = os.path.join(claude_log_dir, f"claude_{timestamp}_{log_id}.log")
try:
with open(claude_log_file, "w") as f:
f.write(message)
# Log to server log as well
if type == "error":
logger.error(f"[Claude] [{log_id}] {message[:100]}...")
elif type == "warning":
logger.warning(f"[Claude] [{log_id}] {message[:100]}...")
elif type == "debug":
logger.debug(f"[Claude] [{log_id}] {message[:100]}...")
else:
logger.info(f"[Claude] [{log_id}] {message[:100]}...")
return json.dumps({
"success": True,
"logId": log_id,
"logFile": claude_log_file
})
except Exception as e:
logger.error(f"Error logging Claude message: {str(e)}")
return json.dumps({
"success": False,
"error": f"Error logging Claude message: {str(e)}"
})
def main():
"""Main function to run the MCP server"""
logger.info("=== RhinoMCP Server Starting ===")
logger.info(f"Process ID: {os.getpid()}")
try:
# Run the FastMCP server
mcp.run()
except KeyboardInterrupt:
logger.info("Keyboard interrupt received")
except Exception as e:
logger.error(f"Error running server: {str(e)}")
logger.error(f"Traceback: {traceback.format_exc()}")
finally:
logger.info("Server shutting down...")
# Clean up connection
global _rhino_connection
if _rhino_connection:
_rhino_connection.disconnect()
if os.path.exists(pid_file):
os.remove(pid_file)
logger.info("Removed PID file")
return 0
if __name__ == "__main__":
sys.exit(main())
```