commit 39f4ad9412177bd7c338e2aedb3af049b7f3b7ef Author: architeur Date: Tue Dec 9 01:04:57 2025 +0100 Initial project setup for RhinoMcp - RhinoMcp.Plugin: Rhino 8 plugin with TCP listener on port 9744 - RhinoMcp.Bridge: MCP HTTP server on port 9743 - Basic MCP tools: ping, get_info, run_command, get_layers, get_objects - Project documentation: CLAUDE.md, HANDOVER.md 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d8f4e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Rider +.idea/ + +# NuGet +*.nupkg +**/packages/* + +# OS +.DS_Store +Thumbs.db + +# Rhino Plugin output +*.rhp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1119262 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md - RhinoMcp Projektanweisungen + +> Diese Datei enthält Anweisungen für Claude Code bei der Arbeit an diesem Projekt. + +## Projekt-Übersicht + +RhinoMcp ist ein MCP-Server (Model Context Protocol) zur Fernsteuerung von Rhino 8. +Das Projekt besteht aus zwei Komponenten: +1. **RhinoMcp.Plugin** - Rhino 8 Plugin das auf Befehle lauscht +2. **RhinoMcp.Bridge** - Standalone MCP-Server der mit Claude kommuniziert + +## Architektur + +``` +Claude Code ──HTTP/MCP──► RhinoMcp.Bridge ──TCP──► RhinoMcp.Plugin ──► Rhino 8 + :9743 :9744 RhinoCommon API +``` + +## Build-Befehle + +```bash +# Bridge bauen +cd src/RhinoMcp.Bridge +dotnet build + +# Plugin bauen (benötigt Rhino 8 SDK) +cd src/RhinoMcp.Plugin +dotnet build +``` + +## Konventionen + +### Code-Stil +- C# 12, .NET 8.0 für Bridge, .NET 7.0 für Plugin (Rhino 8 Kompatibilität) +- Nullable reference types aktiviert +- Deutsche Kommentare, englische Identifier +- Max 300 Zeilen pro Datei + +### Datei-Organisation +``` +src/ +├── RhinoMcp.Plugin/ # Rhino 8 Plugin +│ ├── RhinoMcpPlugin.cs # Plugin Entry Point +│ ├── CommandListener.cs # TCP Server in Rhino +│ └── Commands/ # Rhino-Befehle +│ +└── RhinoMcp.Bridge/ # MCP Server + ├── Program.cs # Entry Point + └── Services/ + ├── McpServer.cs # HTTP MCP Server + ├── RhinoConnection.cs # TCP Client zu Rhino + └── McpToolHandler.cs # Tool-Implementierungen +``` + +### MCP-Tool Namenskonvention +- Prefix: `rhino_` +- Beispiele: `rhino_run_command`, `rhino_get_objects`, `rhino_create_layer` + +## Wichtige Dateien + +- `CLAUDE.md` - Diese Datei (Anweisungen für Claude) +- `HANDOVER.md` - Session-Übergabe Status +- `src/RhinoMcp.Bridge/Services/McpServer.cs` - MCP HTTP Server +- `src/RhinoMcp.Plugin/RhinoMcpPlugin.cs` - Rhino Plugin Entry + +## Git & Deployment + +- Repository: `ssh://git@git.artetui.de:22222/admin/RhinoMcp.git` +- Branch: `master` +- Commit-Messages auf Englisch mit Emoji-Prefix + +## Rhino 8 Spezifika + +- RhinoCommon NuGet: `RhinoCommon` (Version 8.x) +- Plugin GUID muss eindeutig sein +- Plugin wird nach `%APPDATA%\McNeel\Rhinoceros\8.0\Plug-ins\` kopiert diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..75770ce --- /dev/null +++ b/HANDOVER.md @@ -0,0 +1,53 @@ +# Session-Übergabe + +> Diese Datei wird bei jeder Übergabe ÜBERSCHRIEBEN, nicht ergänzt. + +## Letztes Update + +- Datum: 2025-12-09 +- Grund: Projekt-Initialisierung + +## Aktueller Stand + +- Projekt-Grundgerüst erstellt +- Git-Repository initialisiert +- CLAUDE.md mit Projektkonventionen erstellt + +## Architektur + +``` +Claude Code ──HTTP/MCP──► RhinoMcp.Bridge ──TCP──► RhinoMcp.Plugin ──► Rhino 8 + :9743 :9744 RhinoCommon API +``` + +## Projektstruktur + +``` +C:\RhinoMcp\ +├── CLAUDE.md # Projektanweisungen für Claude +├── HANDOVER.md # Diese Datei +├── docs/ # Dokumentation +└── src/ + ├── RhinoMcp.Plugin/ # Rhino 8 Plugin (C#, .NET 7.0) + └── RhinoMcp.Bridge/ # MCP Server (C#, .NET 8.0) +``` + +## Nächste Schritte + +1. [ ] RhinoMcp.Bridge Projekt erstellen mit MCP-Server +2. [ ] RhinoMcp.Plugin Projekt erstellen mit TCP-Listener +3. [ ] Erste MCP-Tools implementieren (rhino_run_command, rhino_get_info) +4. [ ] Plugin in Rhino testen +5. [ ] Gitea Repository erstellen und pushen + +## Build-Befehle + +```bash +cd src/RhinoMcp.Bridge && dotnet build +cd src/RhinoMcp.Plugin && dotnet build +``` + +## Git + +- Remote: `ssh://git@git.artetui.de:22222/admin/RhinoMcp.git` +- Branch: `master` diff --git a/src/RhinoMcp.Bridge/Program.cs b/src/RhinoMcp.Bridge/Program.cs new file mode 100644 index 0000000..8201716 --- /dev/null +++ b/src/RhinoMcp.Bridge/Program.cs @@ -0,0 +1,22 @@ +using RhinoMcp.Bridge.Services; + +namespace RhinoMcp.Bridge; + +class Program +{ + static async Task Main(string[] args) + { + Console.WriteLine("RhinoMcp Bridge startet..."); + + var mcpServer = new McpServer(); + var rhinoConnection = new RhinoConnection(); + + // Rhino-Verbindung dem MCP-Server bekannt machen + McpToolHandler.Initialize(rhinoConnection); + + // Server starten + await mcpServer.StartAsync(); + + Console.WriteLine("RhinoMcp Bridge beendet."); + } +} diff --git a/src/RhinoMcp.Bridge/RhinoMcp.Bridge.csproj b/src/RhinoMcp.Bridge/RhinoMcp.Bridge.csproj new file mode 100644 index 0000000..e2a9d9f --- /dev/null +++ b/src/RhinoMcp.Bridge/RhinoMcp.Bridge.csproj @@ -0,0 +1,11 @@ + + + + Exe + net8.0 + enable + enable + win-x64 + + + diff --git a/src/RhinoMcp.Bridge/Services/McpServer.cs b/src/RhinoMcp.Bridge/Services/McpServer.cs new file mode 100644 index 0000000..0c18977 --- /dev/null +++ b/src/RhinoMcp.Bridge/Services/McpServer.cs @@ -0,0 +1,221 @@ +using System.Net; +using System.Text; +using System.Text.Json; + +namespace RhinoMcp.Bridge.Services; + +/// +/// MCP (Model Context Protocol) Server für Claude Code Integration mit Rhino 8. +/// Läuft auf localhost:9743 +/// +public class McpServer : IDisposable +{ + private readonly HttpListener _listener; + private CancellationTokenSource? _cts; + private Task? _serverTask; + + public const int DefaultPort = 9743; + public bool IsRunning { get; private set; } + public string BaseUrl => $"http://localhost:{DefaultPort}/"; + + public McpServer() + { + _listener = new HttpListener(); + _listener.Prefixes.Add(BaseUrl); + } + + /// + /// Startet den MCP-Server und wartet bis Ctrl+C gedrückt wird. + /// + public async Task StartAsync() + { + if (IsRunning) return; + + try + { + _listener.Start(); + _cts = new CancellationTokenSource(); + IsRunning = true; + Console.WriteLine($"[MCP] Server gestartet auf {BaseUrl}"); + Console.WriteLine("[MCP] Drücke Ctrl+C zum Beenden"); + + // Ctrl+C Handler + Console.CancelKeyPress += (s, e) => + { + e.Cancel = true; + _cts.Cancel(); + }; + + await ListenAsync(_cts.Token); + } + catch (Exception ex) + { + Console.WriteLine($"[MCP] Fehler beim Starten: {ex.Message}"); + } + finally + { + Stop(); + } + } + + /// + /// Stoppt den MCP-Server. + /// + public void Stop() + { + if (!IsRunning) return; + + _cts?.Cancel(); + _listener.Stop(); + IsRunning = false; + Console.WriteLine("[MCP] Server gestoppt"); + } + + private async Task ListenAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested && _listener.IsListening) + { + try + { + var context = await _listener.GetContextAsync(); + _ = Task.Run(() => HandleRequestAsync(context), ct); + } + catch (HttpListenerException) when (ct.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + Console.WriteLine($"[MCP] Listener-Fehler: {ex.Message}"); + } + } + } + + private async Task HandleRequestAsync(HttpListenerContext context) + { + var request = context.Request; + var response = context.Response; + + // CORS Headers + response.Headers.Add("Access-Control-Allow-Origin", "*"); + response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.Headers.Add("Access-Control-Allow-Headers", "Content-Type"); + + try + { + if (request.HttpMethod == "OPTIONS") + { + response.StatusCode = 200; + response.Close(); + return; + } + + var path = request.Url?.AbsolutePath ?? "/"; + var result = path switch + { + "/" => GetServerInfo(), + "/tools" => GetToolList(), + "/call" => await HandleToolCall(request), + _ => new McpResponse { Error = $"Unknown path: {path}" } + }; + + var json = JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + var buffer = Encoding.UTF8.GetBytes(json); + response.ContentType = "application/json"; + response.ContentLength64 = buffer.Length; + await response.OutputStream.WriteAsync(buffer); + } + catch (Exception ex) + { + var error = JsonSerializer.Serialize(new McpResponse { Error = ex.Message }); + var buffer = Encoding.UTF8.GetBytes(error); + response.StatusCode = 500; + response.ContentType = "application/json"; + response.ContentLength64 = buffer.Length; + await response.OutputStream.WriteAsync(buffer); + } + finally + { + response.Close(); + } + } + + private McpResponse GetServerInfo() + { + return new McpResponse + { + Success = true, + Data = new + { + name = "RhinoMcp Bridge", + version = "1.0.0", + description = "MCP-Schnittstelle für Rhino 8 - Geometrie, Layer, Befehle", + endpoints = new[] { "/", "/tools", "/call" }, + rhinoPort = 9744 + } + }; + } + + private McpResponse GetToolList() + { + return new McpResponse + { + Success = true, + Data = McpToolDefinitions.AllTools + }; + } + + private async Task HandleToolCall(HttpListenerRequest request) + { + if (request.HttpMethod != "POST") + { + return new McpResponse { Error = "POST required for /call" }; + } + + using var reader = new StreamReader(request.InputStream, request.ContentEncoding); + var body = await reader.ReadToEndAsync(); + + var call = JsonSerializer.Deserialize(body, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + if (call == null || string.IsNullOrEmpty(call.Tool)) + { + return new McpResponse { Error = "Invalid tool call" }; + } + + return await McpToolHandler.ExecuteAsync(call); + } + + public void Dispose() + { + Stop(); + _listener.Close(); + _cts?.Dispose(); + } +} + +/// +/// MCP Response-Struktur +/// +public class McpResponse +{ + public bool Success { get; set; } + public string? Error { get; set; } + public object? Data { get; set; } +} + +/// +/// MCP Tool-Aufruf +/// +public class McpToolCall +{ + public string Tool { get; set; } = ""; + public Dictionary? Args { get; set; } +} diff --git a/src/RhinoMcp.Bridge/Services/McpToolDefinitions.cs b/src/RhinoMcp.Bridge/Services/McpToolDefinitions.cs new file mode 100644 index 0000000..c9a6c3a --- /dev/null +++ b/src/RhinoMcp.Bridge/Services/McpToolDefinitions.cs @@ -0,0 +1,142 @@ +namespace RhinoMcp.Bridge.Services; + +/// +/// MCP Tool-Definitionen für Rhino 8 +/// +public static class McpToolDefinitions +{ + public static readonly List AllTools = new() + { + // === VERBINDUNG === + new McpToolDef + { + Name = "rhino_ping", + Description = "Prüft die Verbindung zu Rhino 8", + Parameters = new() + }, + + new McpToolDef + { + Name = "rhino_get_info", + Description = "Gibt Informationen über Rhino und das aktive Dokument zurück", + Parameters = new() + }, + + // === BEFEHLE === + new McpToolDef + { + Name = "rhino_run_command", + Description = "Führt einen Rhino-Befehl aus (wie in der Kommandozeile)", + Parameters = new() + { + ["command"] = new ParamDef + { + Type = "string", + Description = "Der auszuführende Rhino-Befehl (z.B. '_Line', '_Circle 0,0,0 10')", + Required = true + } + } + }, + + new McpToolDef + { + Name = "rhino_run_script", + Description = "Führt ein Python- oder GH-Script in Rhino aus", + Parameters = new() + { + ["script"] = new ParamDef + { + Type = "string", + Description = "Der Script-Code", + Required = true + }, + ["language"] = new ParamDef + { + Type = "string", + Description = "Script-Sprache: python (default), ghpython" + } + } + }, + + // === LAYER === + new McpToolDef + { + Name = "rhino_get_layers", + Description = "Gibt alle Layer im aktiven Dokument zurück", + Parameters = new() + }, + + new McpToolDef + { + Name = "rhino_create_layer", + Description = "Erstellt einen neuen Layer", + Parameters = new() + { + ["name"] = new ParamDef + { + Type = "string", + Description = "Name des neuen Layers", + Required = true + }, + ["color"] = new ParamDef + { + Type = "string", + Description = "Farbe als Hex-Code (z.B. #FF0000)" + } + } + }, + + // === OBJEKTE === + new McpToolDef + { + Name = "rhino_get_objects", + Description = "Gibt Objekte im Dokument zurück (optional gefiltert)", + Parameters = new() + { + ["layer"] = new ParamDef + { + Type = "string", + Description = "Filter nach Layer-Name" + }, + ["type"] = new ParamDef + { + Type = "string", + Description = "Filter nach Objekttyp (Curve, Surface, Brep, Mesh, etc.)" + } + } + }, + + new McpToolDef + { + Name = "rhino_select_objects", + Description = "Selektiert Objekte nach Kriterien", + Parameters = new() + { + ["layer"] = new ParamDef + { + Type = "string", + Description = "Selektiere alle Objekte auf diesem Layer" + }, + ["type"] = new ParamDef + { + Type = "string", + Description = "Selektiere alle Objekte dieses Typs" + } + } + } + }; +} + +public class McpToolDef +{ + public string Name { get; set; } = ""; + public string Description { get; set; } = ""; + public Dictionary Parameters { get; set; } = new(); +} + +public class ParamDef +{ + public string Type { get; set; } = "string"; + public string Description { get; set; } = ""; + public bool Required { get; set; } +} diff --git a/src/RhinoMcp.Bridge/Services/McpToolHandler.cs b/src/RhinoMcp.Bridge/Services/McpToolHandler.cs new file mode 100644 index 0000000..95ddc62 --- /dev/null +++ b/src/RhinoMcp.Bridge/Services/McpToolHandler.cs @@ -0,0 +1,172 @@ +using System.Text.Json; + +namespace RhinoMcp.Bridge.Services; + +/// +/// Handler für MCP Tool-Aufrufe - leitet an Rhino weiter +/// +public static class McpToolHandler +{ + private static RhinoConnection? _rhino; + + public static void Initialize(RhinoConnection rhinoConnection) + { + _rhino = rhinoConnection; + } + + public static async Task ExecuteAsync(McpToolCall call) + { + if (_rhino == null) + { + return new McpResponse { Error = "RhinoConnection nicht initialisiert" }; + } + + try + { + return call.Tool switch + { + "rhino_ping" => await HandlePing(), + "rhino_get_info" => await HandleGetInfo(), + "rhino_run_command" => await HandleRunCommand(call.Args), + "rhino_get_layers" => await HandleGetLayers(), + "rhino_get_objects" => await HandleGetObjects(call.Args), + "rhino_create_layer" => await HandleCreateLayer(call.Args), + "rhino_select_objects" => await HandleSelectObjects(call.Args), + "rhino_run_script" => await HandleRunScript(call.Args), + _ => new McpResponse { Error = $"Unbekanntes Tool: {call.Tool}" } + }; + } + catch (Exception ex) + { + return new McpResponse { Error = ex.Message }; + } + } + + private static async Task HandlePing() + { + var result = await _rhino!.SendCommandAsync("ping"); + return WrapResult(result); + } + + private static async Task HandleGetInfo() + { + var result = await _rhino!.SendCommandAsync("get_info"); + return WrapResult(result); + } + + private static async Task HandleRunCommand(Dictionary? args) + { + var command = GetStringArg(args, "command"); + if (string.IsNullOrEmpty(command)) + { + return new McpResponse { Error = "command parameter required" }; + } + + var result = await _rhino!.SendCommandAsync("run_command", new Dictionary + { + ["command"] = command + }); + return WrapResult(result); + } + + private static async Task HandleGetLayers() + { + var result = await _rhino!.SendCommandAsync("get_layers"); + return WrapResult(result); + } + + private static async Task HandleGetObjects(Dictionary? args) + { + var rhinoArgs = new Dictionary(); + + var layer = GetStringArg(args, "layer"); + if (!string.IsNullOrEmpty(layer)) + rhinoArgs["layer"] = layer; + + var type = GetStringArg(args, "type"); + if (!string.IsNullOrEmpty(type)) + rhinoArgs["type"] = type; + + var result = await _rhino!.SendCommandAsync("get_objects", rhinoArgs); + return WrapResult(result); + } + + private static async Task HandleCreateLayer(Dictionary? args) + { + var name = GetStringArg(args, "name"); + if (string.IsNullOrEmpty(name)) + { + return new McpResponse { Error = "name parameter required" }; + } + + var rhinoArgs = new Dictionary { ["name"] = name }; + + var color = GetStringArg(args, "color"); + if (!string.IsNullOrEmpty(color)) + rhinoArgs["color"] = color; + + var result = await _rhino!.SendCommandAsync("create_layer", rhinoArgs); + return WrapResult(result); + } + + private static async Task HandleSelectObjects(Dictionary? args) + { + var rhinoArgs = new Dictionary(); + + var layer = GetStringArg(args, "layer"); + if (!string.IsNullOrEmpty(layer)) + rhinoArgs["layer"] = layer; + + var type = GetStringArg(args, "type"); + if (!string.IsNullOrEmpty(type)) + rhinoArgs["type"] = type; + + var result = await _rhino!.SendCommandAsync("select_objects", rhinoArgs); + return WrapResult(result); + } + + private static async Task HandleRunScript(Dictionary? args) + { + var script = GetStringArg(args, "script"); + if (string.IsNullOrEmpty(script)) + { + return new McpResponse { Error = "script parameter required" }; + } + + var language = GetStringArg(args, "language") ?? "python"; + + var result = await _rhino!.SendCommandAsync("run_script", new Dictionary + { + ["script"] = script, + ["language"] = language + }); + return WrapResult(result); + } + + #region Helpers + + private static string? GetStringArg(Dictionary? args, string key) + { + if (args == null || !args.TryGetValue(key, out var element)) + return null; + + return element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString(); + } + + private static McpResponse WrapResult(JsonElement? result) + { + if (result == null) + { + return new McpResponse { Error = "Keine Verbindung zu Rhino - ist Rhino 8 gestartet und das Plugin geladen?" }; + } + + if (result.Value.TryGetProperty("error", out var errorProp)) + { + return new McpResponse { Error = errorProp.GetString() }; + } + + return new McpResponse { Success = true, Data = result }; + } + + #endregion +} diff --git a/src/RhinoMcp.Bridge/Services/RhinoConnection.cs b/src/RhinoMcp.Bridge/Services/RhinoConnection.cs new file mode 100644 index 0000000..5f7a366 --- /dev/null +++ b/src/RhinoMcp.Bridge/Services/RhinoConnection.cs @@ -0,0 +1,107 @@ +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace RhinoMcp.Bridge.Services; + +/// +/// TCP-Verbindung zum RhinoMcp Plugin in Rhino 8 +/// +public class RhinoConnection : IDisposable +{ + private TcpClient? _client; + private NetworkStream? _stream; + private readonly object _lock = new(); + + public const string Host = "127.0.0.1"; + public const int Port = 9744; + public bool IsConnected => _client?.Connected ?? false; + + /// + /// Verbindet zum Rhino Plugin + /// + public bool Connect() + { + lock (_lock) + { + try + { + Disconnect(); + _client = new TcpClient(); + _client.Connect(Host, Port); + _stream = _client.GetStream(); + Console.WriteLine($"[Rhino] Verbunden mit Rhino auf Port {Port}"); + return true; + } + catch (Exception ex) + { + Console.WriteLine($"[Rhino] Verbindungsfehler: {ex.Message}"); + return false; + } + } + } + + /// + /// Trennt die Verbindung + /// + public void Disconnect() + { + lock (_lock) + { + _stream?.Dispose(); + _client?.Dispose(); + _stream = null; + _client = null; + } + } + + /// + /// Sendet einen Befehl an Rhino und wartet auf Antwort + /// + public async Task SendCommandAsync(string command, Dictionary? args = null) + { + // Bei Bedarf verbinden + if (!IsConnected && !Connect()) + { + return null; + } + + var request = new + { + Command = command, + Args = args + }; + + var json = JsonSerializer.Serialize(request); + var buffer = Encoding.UTF8.GetBytes(json); + + try + { + await _stream!.WriteAsync(buffer); + + // Antwort lesen + var responseBuffer = new byte[65536]; + var bytesRead = await _stream.ReadAsync(responseBuffer); + + if (bytesRead == 0) + { + Disconnect(); + return null; + } + + var responseJson = Encoding.UTF8.GetString(responseBuffer, 0, bytesRead).Trim(); + return JsonSerializer.Deserialize(responseJson); + } + catch (Exception ex) + { + Console.WriteLine($"[Rhino] Kommunikationsfehler: {ex.Message}"); + Disconnect(); + return null; + } + } + + public void Dispose() + { + Disconnect(); + } +} diff --git a/src/RhinoMcp.Plugin/RhinoMcp.Plugin.csproj b/src/RhinoMcp.Plugin/RhinoMcp.Plugin.csproj new file mode 100644 index 0000000..2ce1a3b --- /dev/null +++ b/src/RhinoMcp.Plugin/RhinoMcp.Plugin.csproj @@ -0,0 +1,18 @@ + + + + net7.0-windows + enable + enable + true + true + + + + + all + runtime + + + + diff --git a/src/RhinoMcp.Plugin/RhinoMcpPlugin.cs b/src/RhinoMcp.Plugin/RhinoMcpPlugin.cs new file mode 100644 index 0000000..b5b83f6 --- /dev/null +++ b/src/RhinoMcp.Plugin/RhinoMcpPlugin.cs @@ -0,0 +1,230 @@ +using Rhino; +using Rhino.PlugIns; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace RhinoMcp.Plugin; + +/// +/// RhinoMcp Plugin - Lauscht auf TCP-Befehle von der MCP Bridge +/// +public class RhinoMcpPlugin : PlugIn +{ + private TcpListener? _listener; + private CancellationTokenSource? _cts; + private const int DefaultPort = 9744; + + public static RhinoMcpPlugin? Instance { get; private set; } + + public RhinoMcpPlugin() + { + Instance = this; + } + + public override PlugInLoadTime LoadTime => PlugInLoadTime.AtStartup; + + protected override LoadReturnCode OnLoad(ref string errorMessage) + { + RhinoApp.WriteLine("RhinoMcp Plugin wird geladen..."); + + // TCP Server starten + StartTcpServer(); + + RhinoApp.WriteLine($"RhinoMcp Plugin aktiv auf Port {DefaultPort}"); + return LoadReturnCode.Success; + } + + protected override void OnShutdown() + { + StopTcpServer(); + base.OnShutdown(); + } + + private void StartTcpServer() + { + _cts = new CancellationTokenSource(); + _listener = new TcpListener(IPAddress.Loopback, DefaultPort); + _listener.Start(); + + Task.Run(async () => await AcceptClientsAsync(_cts.Token)); + } + + private void StopTcpServer() + { + _cts?.Cancel(); + _listener?.Stop(); + } + + private async Task AcceptClientsAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + var client = await _listener!.AcceptTcpClientAsync(ct); + _ = HandleClientAsync(client, ct); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + RhinoApp.WriteLine($"RhinoMcp Fehler: {ex.Message}"); + } + } + } + + private async Task HandleClientAsync(TcpClient client, CancellationToken ct) + { + using (client) + await using (var stream = client.GetStream()) + { + var buffer = new byte[8192]; + + while (!ct.IsCancellationRequested && client.Connected) + { + try + { + var bytesRead = await stream.ReadAsync(buffer, ct); + if (bytesRead == 0) break; + + var request = Encoding.UTF8.GetString(buffer, 0, bytesRead); + var response = await ProcessRequestAsync(request); + + var responseBytes = Encoding.UTF8.GetBytes(response + "\n"); + await stream.WriteAsync(responseBytes, ct); + } + catch (Exception ex) + { + RhinoApp.WriteLine($"RhinoMcp Client-Fehler: {ex.Message}"); + break; + } + } + } + } + + private Task ProcessRequestAsync(string requestJson) + { + try + { + var request = JsonSerializer.Deserialize(requestJson); + if (request == null) + return Task.FromResult(JsonSerializer.Serialize(new { error = "Invalid request" })); + + // Befehle auf dem UI-Thread ausführen + object? result = null; + Exception? error = null; + + RhinoApp.InvokeOnUiThread(() => + { + try + { + result = ExecuteCommand(request); + } + catch (Exception ex) + { + error = ex; + } + }); + + if (error != null) + return Task.FromResult(JsonSerializer.Serialize(new { error = error.Message })); + + return Task.FromResult(JsonSerializer.Serialize(new { success = true, result })); + } + catch (Exception ex) + { + return Task.FromResult(JsonSerializer.Serialize(new { error = ex.Message })); + } + } + + private object? ExecuteCommand(RhinoRequest request) + { + return request.Command?.ToLower() switch + { + "ping" => new { message = "pong", version = RhinoApp.Version.ToString() }, + "run_command" => RunRhinoCommand(request.Args), + "get_info" => GetRhinoInfo(), + "get_layers" => GetLayers(), + "get_objects" => GetObjects(request.Args), + _ => throw new ArgumentException($"Unbekannter Befehl: {request.Command}") + }; + } + + private object RunRhinoCommand(Dictionary? args) + { + var command = args?.GetValueOrDefault("command")?.ToString(); + if (string.IsNullOrEmpty(command)) + throw new ArgumentException("command parameter required"); + + var result = RhinoApp.RunScript(command, echo: false); + return new { executed = result, command }; + } + + private object GetRhinoInfo() + { + var doc = RhinoDoc.ActiveDoc; + return new + { + version = RhinoApp.Version.ToString(), + document = doc?.Name ?? "(kein Dokument)", + units = doc?.ModelUnitSystem.ToString() ?? "unknown", + objectCount = doc?.Objects.Count ?? 0, + layerCount = doc?.Layers.Count ?? 0 + }; + } + + private object GetLayers() + { + var doc = RhinoDoc.ActiveDoc; + if (doc == null) return new { error = "Kein aktives Dokument" }; + + var layers = doc.Layers + .Where(l => !l.IsDeleted) + .Select(l => new + { + id = l.Id, + name = l.Name, + fullPath = l.FullPath, + visible = l.IsVisible, + locked = l.IsLocked, + color = $"#{l.Color.R:X2}{l.Color.G:X2}{l.Color.B:X2}" + }) + .ToList(); + + return new { count = layers.Count, layers }; + } + + private object GetObjects(Dictionary? args) + { + var doc = RhinoDoc.ActiveDoc; + if (doc == null) return new { error = "Kein aktives Dokument" }; + + var layerFilter = args?.GetValueOrDefault("layer")?.ToString(); + var typeFilter = args?.GetValueOrDefault("type")?.ToString(); + + var objects = doc.Objects + .Where(o => !o.IsDeleted) + .Where(o => layerFilter == null || doc.Layers[o.Attributes.LayerIndex].Name == layerFilter) + .Select(o => new + { + id = o.Id, + type = o.ObjectType.ToString(), + layer = doc.Layers[o.Attributes.LayerIndex].Name, + name = o.Attributes.Name ?? "" + }) + .Take(100) // Limitieren um Performance zu wahren + .ToList(); + + return new { count = objects.Count, objects }; + } +} + +public class RhinoRequest +{ + public string? Command { get; set; } + public Dictionary? Args { get; set; } +}