diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e7381d6 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Unreal MCP Server Configuration + +# Path to your Unreal project (required for offline mode) +UE_PROJECT_PATH=C:/Path/To/Your/UnrealProject + +# TCP Command Port (default: 6776) +# This should match your Unreal Editor Python Remote Execution settings +UE_COMMAND_PORT=6776 + +# UDP Multicast settings for node discovery +UE_MULTICAST_GROUP=239.0.0.1 +UE_MULTICAST_PORT=6766 + +# Logging level (DEBUG, INFO, WARNING, ERROR) +LOG_LEVEL=INFO diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..906c11d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "unreal-mcp": { + "command": "python", + "args": ["-m", "unreal_mcp.server"], + "env": { + "UE_PROJECT_PATH": "", + "UE_COMMAND_PORT": "6776", + "UE_MULTICAST_PORT": "6766" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ce3cb62 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,127 @@ +# Unreal MCP Server - Dev Guide + +MCP server Python pour Unreal Engine : Runtime + Project Intelligence + +--- + +## Architecture + +``` +┌─────────────────────────────────┐ +│ UNREAL MCP SERVER (Python) │ +├─────────────────────────────────┤ +│ MCP Tools (12) │ +│ └── Remote Execution Protocol │ +├─────────────────────────────────┤ +│ Fallback (éditeur fermé) │ +│ └── .uasset parser + scan │ +└─────────────────────────────────┘ + +User's CLAUDE.md → Expertise Unreal C++ (PAS NOUS!) +Skills → /blueprint-workflow +MCP Tools → get_spawnable_classes(), spawn_actor(), debug... +``` + +--- + +## Structure + +``` +src/unreal_mcp/ +├── server.py # MCP entry point +├── tools/ +│ ├── project.py # get_spawnable_classes, get_project_assets, scan_cpp_classes +│ ├── scene.py # spawn_actor, get_scene_hierarchy, modify_actor_transform +│ ├── debug.py # get_console_logs, analyze_crash_dump, profile_blueprint +│ └── blueprint.py # read_blueprint, create_blueprint_from_cpp, execute_python_script +├── core/ +│ ├── unreal_connection.py # Remote Execution Protocol (UDP+TCP) +│ └── uasset_parser.py # Parse .uasset (fallback) +└── utils/ + ├── config.py # Settings (pydantic-settings) + ├── logger.py + └── validation.py + +skills/ +└── blueprint-workflow/ + ├── skill.yaml # 5 commands: analyze, bp-to-cpp, cpp-to-bp, transform, optimize + └── prompt.md +``` + +--- + +## MCP Tools (12) + +| Category | Tools | +|----------|-------| +| Project | `get_spawnable_classes`, `get_project_assets`, `scan_cpp_classes` | +| Scene | `spawn_actor`, `get_scene_hierarchy`, `modify_actor_transform` | +| Debug | `get_console_logs`, `analyze_crash_dump`, `profile_blueprint` | +| Blueprint | `read_blueprint`, `create_blueprint_from_cpp`, `execute_python_script` | + +--- + +## Remote Execution Protocol + +**UDP Multicast** (Node Discovery) +- Group: `239.0.0.1` +- Port: `6766` +- Messages: `ping`, `pong`, `open_connection`, `close_connection` + +**TCP** (Commands) +- Port: `6776` +- Messages: `command`, `command_result` +- Format: 4-byte length prefix + JSON UTF-8 + +```python +from unreal_mcp.core.unreal_connection import UnrealConnection + +with UnrealConnection(project_path=Path("/path/to/project")) as conn: + result = await conn.execute("print('Hello from Unreal!')") +``` + +--- + +## Env + +```bash +# Project path (for offline mode) +UE_PROJECT_PATH=/path/to/project + +# TCP Command port (default: 6776) +UE_COMMAND_PORT=6776 + +# UDP Multicast (default: 239.0.0.1:6766) +UE_MULTICAST_GROUP=239.0.0.1 +UE_MULTICAST_PORT=6766 + +# Logging +LOG_LEVEL=INFO +``` + +--- + +## Guidelines + +- Python 3.11+ +- Type hints partout +- Validation avec Pydantic +- Graceful fallback si éditeur fermé +- Return `{ "success": True/False, "data"/"error": ... }` +- Tests avec pytest (21 tests) + +--- + +## Status + +- ✅ Architecture +- ✅ MCP SDK setup +- ✅ 12 tools implémentés +- ✅ Remote Execution Protocol (real) +- ✅ /blueprint-workflow skill +- ✅ Tests (21 passed) +- ✅ Fallback offline mode + +--- + +**Remember**: L'expertise Unreal C++ sera dans le CLAUDE.md des users, pas ici. diff --git a/README.md b/README.md index fcad8f6..818b38a 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,312 @@ -# Unreal Engine MCP Server +# Unreal Engine Development Suite -AI-powered Unreal Engine development - Generate C++ code, Blueprint conversions, and multiplayer systems +AI-powered Unreal Engine development combining native C++ expertise, Blueprint workflows, and live editor interaction. -## Status: Setup phase +## What is it? -See [docs/SPEC.md](docs/SPEC.md) for complete specifications. +A complete development suite for Unreal Engine featuring: +- **CLAUDE.md** - Expert C++ code generation (no tools needed!) +- **Skills** - Blueprint workflows (bidirectional BP <-> C++) +- **MCP Server** - Runtime debugging + project intelligence + +**Unique**: The only solution offering native C++ generation + bidirectional Blueprint workflows + live editor interaction. + +--- + +## Architecture + +``` +CLAUDE.md (Expert Context) + | Natural C++ generation +Skills (Blueprint Workflows) + | Uses MCP when needed +MCP Server (Python - Runtime + Intelligence) + -> get_spawnable_classes() + -> spawn_actor() + -> debug tools +``` + +--- ## Features -- Generate Unreal C++ classes (.h + .cpp) from natural language -- Blueprint → C++ conversion for performance optimization -- Create complete gameplay systems (weapons, abilities, inventory) -- Network replication code generation (multiplayer) -- Debug Unreal code with AI assistance -- Performance analysis and profiling recommendations -- Animation Blueprint logic generation -- Slate UI for editor customization +### C++ Generation (via CLAUDE.md) +``` +User: "Create an AWeapon with ammo and reload" +-> Claude generates production-ready C++ (no MCP tool!) +``` -## Target +- Production-ready code that compiles first try +- Epic Coding Standards compliant +- Proper Unreal macros (UCLASS, UPROPERTY, UFUNCTION) +- Network replication patterns +- Blueprint-friendly code -- Unreal Engine developers (5M+ worldwide) -- AAA game studios and technical designers -- Multiplayer game developers -- VR/AR experience developers +### Blueprint Workflows (via Skills) -## Tech +**`/blueprint-workflow bp-to-cpp`** - Optimize Blueprints +``` +BP_Enemy.uasset -> Enemy.h/cpp (+120% performance) +``` -- TypeScript MCP server -- Claude API for code generation -- C++ code generation following Epic coding standards -- Proper UE macros (UCLASS, UPROPERTY, UFUNCTION) +**`/blueprint-workflow cpp-to-bp`** - Generate Blueprints +``` +Weapon.h -> BP_Weapon (designer-friendly) +``` + +**`/blueprint-workflow transform`** - Round-Trip Magic +``` +BP_Character -> Add dash ability -> BP_Character_v2 +``` + +**`/blueprint-workflow analyze`** - Performance Analysis +``` +Detects: Tick overhead, Blueprint casts, anti-patterns +``` + +### Live Editor (via MCP) + +**Project Intelligence** +- `get_spawnable_classes()` - Discover what can be spawned +- `get_project_assets()` - List all project assets +- `scan_cpp_classes()` - Find C++ classes + +**Scene Manipulation** +- `spawn_actor()` - Spawn actors in scene +- `get_scene_hierarchy()` - Get level structure +- `modify_actor_transform()` - Move/rotate/scale + +**Debug & Profiling** +- `get_console_logs()` - Real-time logs +- `analyze_crash_dump()` - Crash analysis +- `profile_blueprint()` - Performance profiling + +**Blueprint Operations** +- `create_blueprint_from_cpp()` - Generate BP from C++ +- `compile_blueprint()` - Compile and get errors +- `execute_python_script()` - Run Python in editor + +--- + +## Quick Start + +### 1. Setup CLAUDE.md + +The `CLAUDE.md` file contains expert Unreal C++ context. Claude reads it automatically. + +Just ask Claude to generate code: +``` +"Create a health component with damage and healing" +-> Code generated instantly! +``` + +### 2. Use Skills for Blueprints + +```bash +# Analyze a Blueprint +/blueprint-workflow analyze BP_Enemy.uasset + +# Convert Blueprint to C++ +/blueprint-workflow bp-to-cpp BP_Enemy.uasset + +# Generate Blueprint from C++ +/blueprint-workflow cpp-to-bp Weapon.h + +# Transform with AI +/blueprint-workflow transform BP_Character.uasset "add dash ability" +``` + +### 3. MCP for Runtime/Debug + +The MCP server provides live editor interaction: +``` +User: "Spawn 5 enemies in a circle" +-> Claude uses get_spawnable_classes() +-> Claude spawns actors via spawn_actor() +``` + +--- + +## Installation + +### Prerequisites +- Python 3.11+ +- Unreal Engine 5.3+ (optional, for MCP features) +- Claude Code + +### Setup + +```bash +# Clone repository +git clone https://github.com/AlexisTrouve/unreal-mcp.git +cd unreal-mcp + +# Install with pip +pip install -e . + +# Or with uv (recommended) +uv pip install -e . +``` + +### Configure Claude Code + +The `.mcp.json` file is already included in the repo. Just update `UE_PROJECT_PATH`: + +```json +{ + "mcpServers": { + "unreal-mcp": { + "command": "python", + "args": ["-m", "unreal_mcp.server"], + "env": { + "UE_PROJECT_PATH": "C:/MyProject" + } + } + } +} +``` + +Then restart Claude Code or run `/mcp` to reload MCP servers. + +### Configure Unreal Editor (Optional - for runtime features) + +1. Enable **Python Editor Script Plugin** +2. Enable **Python Remote Execution** in Project Settings +3. Restart Unreal Editor + +--- + +## Usage Examples + +### Example 1: Simple C++ Generation + +``` +User: "Create a rifle with 30 ammo and auto-reload" + +Claude: [Reads CLAUDE.md] + [Generates Rifle.h + Rifle.cpp] + +-> Production-ready C++ code! +``` + +### Example 2: Blueprint Optimization + +``` +User: /blueprint-workflow analyze BP_AI.uasset + +Result: +Performance Analysis: BP_AI + +Issues Found: +- Event Tick: 23 operations/frame (HIGH COST) + -> Suggestion: Replace with Events + Timer + +Estimated Gain: +180% performance +``` + +### Example 3: Scene Manipulation + +``` +User: "Spawn 10 enemies in a circle" + +Claude: [MCP: get_spawnable_classes()] + "Found BP_Enemy" + + [MCP: spawn_actor() x 10] + + 10 enemies spawned in a circle (500 unit radius) +``` + +--- + +## Why This Architecture? + +### Simple Things Stay Simple + +Generate C++ -> Just ask Claude (CLAUDE.md only) +- No tool overhead +- Instant response +- Natural conversation + +### Complex Things Are Powerful + +Blueprint workflows -> Skills handle complexity +- Multi-step processes +- Parsing .uasset files +- Code transformation + +Runtime debugging -> MCP provides live access +- Real-time logs +- Scene manipulation +- Project intelligence + +### Works Online and Offline + +- **CLAUDE.md** - Always available +- **Skills** - Can generate scripts for later +- **MCP** - Gracefully degrades if editor closed + +--- + +## Unique Differentiators + +### vs Other MCP Servers +- **Them:** Runtime scene manipulation only +- **Us:** C++ generation + Blueprint workflows + Runtime + +### vs NodeToCode +- **Them:** Blueprint -> C++ only (one-way) +- **Us:** Bidirectional BP <-> C++ + Round-trip transformations + +### vs Bluepy +- **Them:** Text -> Blueprint only +- **Us:** Full workflows including C++ -> BP + +--- + +## Documentation + +- [**SPEC.md**](docs/SPEC.md) - Complete technical specification +- [**IMPLEMENTATION_PLAN.md**](docs/IMPLEMENTATION_PLAN.md) - Implementation roadmap +- [**REFERENCES.md**](docs/REFERENCES.md) - All research and sources +- [**CLAUDE.md**](CLAUDE.md) - Dev guide + +--- + +## Status + +**Current:** Architecture finalized, Python stack chosen + +### Phase 0: Setup (Current) +- [x] Architecture defined +- [x] Documentation updated for Python +- [ ] pyproject.toml + structure +- [ ] MCP SDK setup + +### Phase 1: Project Intelligence +- [ ] get_spawnable_classes() +- [ ] get_project_assets() +- [ ] Unreal connection + +--- + +## Contributing + +Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +--- + +## License + +MIT License - see [LICENSE](LICENSE) for details + +--- + +## Author + +**Alexis Trouve** +- GitHub: [@AlexisTrouve](https://github.com/AlexisTrouve) + +--- + +**Ready to supercharge your Unreal development!** diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..20163bf --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN.md @@ -0,0 +1,156 @@ +# Unreal MCP Server - Plan d'Implementation + +## Vision + +MCP server Python pour Unreal Engine offrant : +- Generation C++ production-ready (via CLAUDE.md) +- Workflow bidirectionnel Blueprint <-> C++ +- Runtime interaction + debug + +**Differentiation** : Seul MCP avec generation C++ + manipulation Blueprint + analyse statique. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────┐ +│ UNREAL MCP SERVER (Python) │ +├─────────────────────────────────────────┤ +│ Tools MCP │ +│ ├── Project: get_spawnable_classes │ +│ ├── Scene: spawn_actor │ +│ ├── Debug: get_console_logs │ +│ └── Blueprint: create_blueprint │ +├─────────────────────────────────────────┤ +│ Core │ +│ ├── Unreal Connection (port 9998) │ +│ ├── uasset Parser (fallback) │ +│ └── IR (Intermediate Representation) │ +└─────────────────────────────────────────┘ +``` + +--- + +## Stack Technique + +```python +{ + "runtime": "Python 3.11+", + "mcp": "mcp (official Python SDK)", + "validation": "pydantic", + "blueprint_parser": "unreal_asset / custom", + "unreal_connection": "Remote Execution (port 9998)", + "testing": "pytest", + "linting": "ruff" +} +``` + +--- + +## Phases d'Implementation + +### Phase 0 : Setup (Current) +- [x] Architecture definie +- [ ] Init projet Python (pyproject.toml) +- [ ] Setup MCP SDK Python +- [ ] Structure dossiers +- [ ] Tests (pytest) + +### Phase 1 : Project Intelligence +- [ ] `get_spawnable_classes` - Liste classes spawnables +- [ ] `get_project_assets` - Inventaire assets +- [ ] `scan_cpp_classes` - Parse Source/ + +### Phase 2 : Scene Manipulation +- [ ] `spawn_actor` - Spawn via Unreal API +- [ ] `get_scene_hierarchy` - Arbre acteurs +- [ ] `modify_actor_transform` - Deplacer acteurs + +### Phase 3 : Debug Tools +- [ ] `get_console_logs` - Logs temps reel +- [ ] `analyze_crash_dump` - Debug crashes +- [ ] `profile_blueprint` - Performance BP + +### Phase 4 : Blueprint Operations +- [ ] `read_blueprint` - Parse .uasset +- [ ] `create_blueprint_from_cpp` - C++ -> BP +- [ ] `execute_python_script` - Scripts custom + +### Phase 5 : Advanced (Future) +- [ ] `blueprint_to_cpp` - BP -> C++ optimise +- [ ] `transform_blueprint` - Round-trip modifications +- [ ] `optimize_blueprint` - Auto-optimization + +--- + +## Structure Projet + +``` +unreal-mcp/ +├── pyproject.toml # Project config + dependencies +├── README.md +├── CLAUDE.md # Dev guide +├── src/ +│ └── unreal_mcp/ +│ ├── __init__.py +│ ├── server.py # MCP entry point +│ ├── tools/ +│ │ ├── __init__.py +│ │ ├── project.py # get_spawnable_classes, get_project_assets +│ │ ├── scene.py # spawn_actor, get_scene_hierarchy +│ │ ├── debug.py # get_console_logs, analyze_crash +│ │ └── blueprint.py # read_blueprint, create_blueprint +│ ├── core/ +│ │ ├── __init__.py +│ │ ├── unreal_connection.py # Connection Unreal Editor +│ │ └── uasset_parser.py # Parse .uasset files +│ └── utils/ +│ ├── __init__.py +│ ├── logger.py +│ └── validation.py +├── tests/ +│ ├── __init__.py +│ ├── test_tools/ +│ └── test_core/ +├── skills/ +│ └── blueprint-workflow/ +│ ├── skill.yaml +│ └── prompt.md +└── docs/ + ├── SPEC.md + ├── IMPLEMENTATION_PLAN.md + └── REFERENCES.md +``` + +--- + +## Challenges & Solutions + +| Challenge | Solution | +|-----------|----------| +| Format .uasset proprietaire | unreal_asset lib + fallback UAssetAPI | +| Editeur ferme | Fallback: scan Content/ + scripts Python | +| Equivalence BP->C++ | Tests comportementaux + warnings | +| Validation compilation | Syntax check + Epic standards | + +--- + +## Metriques Succes + +- 100% code genere compile +- Response time < 2s +- 90%+ equivalence comportementale BP->C++ + +--- + +## Next Steps + +1. `pyproject.toml` + structure dossiers +2. Setup MCP SDK Python basique +3. Implementer `get_spawnable_classes` +4. Unreal connection (Remote Execution) + +--- + +*Last update: 2026-01-20* diff --git a/docs/REFERENCES.md b/docs/REFERENCES.md new file mode 100644 index 0000000..494bf48 --- /dev/null +++ b/docs/REFERENCES.md @@ -0,0 +1,966 @@ +# Unreal MCP - Références et Ressources + +**Version** : 1.0 +**Date** : 2026-01-20 + +Ce document compile toutes les sources, outils, projets et ressources découverts lors de la phase de recherche. + +--- + +## 📚 Table des Matières + +1. [Projets MCP Existants](#projets-mcp-existants) +2. [Outils Blueprint](#outils-blueprint) +3. [Plugins Commerciaux](#plugins-commerciaux) +4. [Libraries et SDKs](#libraries-et-sdks) +5. [Recherche Académique](#recherche-académique) +6. [Documentation Unreal](#documentation-unreal) +7. [Tutoriels et Guides](#tutoriels-et-guides) +8. [Outils Développement](#outils-développement) + +--- + +## 🔧 Projets MCP Existants + +### Projets Open Source + +#### runreal/unreal-mcp +**Description** : MCP server utilisant Unreal Python Remote Execution +**URL** : https://github.com/runreal/unreal-mcp +**Status** : Actif, Jan 2026 +**Technologies** : Python Remote Execution, pas de plugin UE requis +**Caractéristiques** : +- Pas besoin de nouveau plugin UE +- Utilise protocol Python natif +- Runtime manipulation +**Limitations** : +- Runtime only +- Pas de génération C++ +- Opérations basiques + +#### chongdashu/unreal-mcp +**Description** : Enable AI assistants (Cursor, Claude Desktop) to control Unreal Engine +**URL** : https://github.com/chongdashu/unreal-mcp +**Status** : EXPERIMENTAL +**Technologies** : UE 5.5, Plugin UE +**Caractéristiques** : +- UE 5.5 Blank Starter Project +- UnrealMCP.uplugin included +- Natural language control +**Limitations** : +- État experimental +- API sujette à changements +- Problèmes de connexion fréquents +**Issues Connues** : +- Connection errors "Unexpected token 'C'" +- Requires Python Editor Script Plugin +- Zombie Node.js processes + +#### flopperam/unreal-engine-mcp +**Description** : Control Unreal Engine 5.5+ through AI with natural language +**URL** : https://github.com/flopperam/unreal-engine-mcp +**Status** : Actif +**Capacités** : +- Create entire towns, castles, mansions +- Build mazes, complex structures +- AI-powered commands +**Use Case** : Architectural visualization, world building +**Limitations** : Runtime control, pas de code generation + +#### ChiR24/Unreal_mcp +**Description** : Comprehensive MCP server using native C++ Automation Bridge plugin +**URL** : https://github.com/ChiR24/Unreal_mcp +**Status** : Development +**Technologies** : TypeScript, C++, Rust (WebAssembly) +**Caractéristiques** : +- C++ Automation Bridge +- Ultra-high-performance +- WebAssembly optimization +**Innovation** : Rust WASM integration + +#### kvick-games/UnrealMCP +**Description** : MCP to allow AI agents to control Unreal +**URL** : https://github.com/kvick-games/UnrealMCP +**Status** : Active +**Capacités** : +- Creating objects +- Modifying transforms +- Getting scene info +- Running Python scripts + +#### ayeletstudioindia/unreal-analyzer-mcp +**Description** : MCP server for Unreal Engine 5 code analysis +**URL** : https://github.com/ayeletstudioindia/unreal-analyzer-mcp +**Focus** : Code analysis et parsing +**Use Case** : Static analysis + +#### AlexKissiJr/unreal-mcp-server +**Description** : Another MCP server implementation +**URL** : https://github.com/AlexKissiJr/unreal-mcp-server +**Status** : Development + +#### runeape-sats/unreal-mcp +**Description** : Unreal Engine MCP server for Claude Desktop (early alpha) +**URL** : https://github.com/runeape-sats/unreal-mcp +**Status** : Early alpha preview + +#### VedantRGosavi/UE5-MCP +**Description** : MCP for Unreal Engine 5 +**URL** : https://github.com/VedantRGosavi/UE5-MCP +**Status** : Development + +#### jl-codes/unreal-5-mcp +**Description** : Enable AI assistant clients to control UE through MCP +**URL** : https://github.com/jl-codes/unreal-5-mcp +**Status** : Fork/variant + +### Docker Images + +#### mcp/unreal-engine-mcp-server +**URL** : https://hub.docker.com/r/mcp/unreal-engine-mcp-server +**Description** : Containerized MCP server +**Use Case** : CI/CD, cloud deployments + +--- + +## 🎨 Outils Blueprint + +### Blueprint → C++ Conversion + +#### NodeToCode +**Description** : Translate Unreal Engine Blueprints to C++ in seconds +**URL** : https://github.com/protospatial/NodeToCode +**Status** : Production +**Technologies** : LLM-powered (Claude, OpenAI, Ollama) +**Caractéristiques** : +- Single-click conversion +- Integrated editor experience +- Syntax highlighting +- Implementation notes +- Multi-language output (C#, JS, Python, Swift) +**Pricing** : Commercial plugin +**Innovation** : ⭐ Premier outil de conversion BP→C++ automatique + +#### Blueprint Nativization +**Description** : Built-in UE feature (Project Settings) +**Location** : Project Settings → Packaging → Blueprints +**Caractéristiques** : +- Compile BP to C++ at cook time +- Automatic process +**Limitations** : +- Non-readable code generated +- Not designed for manual use +- Cook-time only + +### Blueprint Génération (AI) + +#### Bluepy +**Description** : Plugin for Unreal to generate blueprints using Gen AI +**URL** : https://github.com/ZackBradshaw/Bluepy +**Author** : Zack Bradshaw +**Status** : Open Source +**Technologies** : OpenAI API +**Workflow** : +1. User describes desired logic in chat +2. Plugin sends to OpenAI API +3. API returns data +4. Plugin generates Blueprint nodes + connections +**Requirements** : +- Unreal Engine 4.26+ +- OpenAI API key +**Tutorial** : https://dev.epicgames.com/community/learning/tutorials/33jJ/unreal-engine-ai-to-generate-blueprints +**Innovation** : ⭐ Premier plugin génération BP via IA + +#### Ultimate Blueprint Generator +**Description** : The AI Co-Pilot for Unreal Engine +**URL** : https://www.fab.com/listings/8d776721-5da3-44ce-b7ef-be17a023be59 +**Type** : Commercial +**Platform** : Unreal Marketplace (Fab) +**Caractéristiques** : +- AI-powered Blueprint generation +- Co-pilot functionality +- Complete graphs generation + +#### Blueprint Generator AI - Kibibyte Labs +**Description** : Engine Assistant with Blueprint generation +**URL** : https://www.fab.com/listings/6aa00d98-0db0-4f13-8950-e21f0a0eda2c +**Type** : Commercial +**Platform** : Fab marketplace + +### Blueprint Analysis + +#### Blueprint Parser - MSR 2025 +**Description** : Research project parsing UE visual scripting at scale +**Paper** : "Under the Blueprints: Parsing Unreal Engine's Visual Scripting at Scale" +**URL** : https://2025.msrconf.org/details/msr-2025-data-and-tool-showcase-track/31/Under-the-Blueprints-Parsing-Unreal-Engine-s-Visual-Scripting-at-Scale +**Dataset** : +- 335,753 Blueprint UAsset files parsed +- 24,009 GitHub projects analyzed +- Custom extractors and parsers +**Purpose** : Academic research +**Publication** : MSR 2025 Conference +**Authors** : Academic research team +**Innovation** : ⭐ Largest Blueprint dataset ever + +--- + +## 💼 Plugins Commerciaux + +### Claude Integration + +#### UnrealClaude +**Description** : Claude Code CLI integration for Unreal Engine 5.7 +**URL** : https://github.com/Natfii/UnrealClaude +**Version UE** : 5.7 +**Caractéristiques** : +- Built-in UE5.7 documentation context +- MCP bridge avec context loader dynamique +- Query by category (animation, blueprint, slate, actor, assets, replication) +- Search by keywords +**Technologies** : Claude Code CLI, MCP +**Status** : Active development + +#### Claude Assistant +**Description** : AI-Powered coding companion for UE5 +**Platform** : Unreal Marketplace +**URL** : https://www.fab.com/listings/4f537f12-452b-4abc-8f1a-c2b5d5eb246b +**Caractéristiques** : +- Integrated into UE5 Editor +- Understands UE architecture +- C++ best practices +- Blueprint systems knowledge +**Type** : Commercial + +#### ClaudeAI Plugin +**Description** : AI-Powered Unreal Engine 5 Assistant +**URL** : https://claudeaiplugin.com/ +**Platform** : Marketplace +**Type** : Commercial + +### Multi-LLM Plugins + +#### UnrealGenAISupport +**Description** : Unreal Engine plugin for LLM/GenAI models & MCP UE5 server +**URL** : https://github.com/prajwalshettydev/UnrealGenAISupport +**LLMs Supported** : +- OpenAI GPT 5.1 +- Deepseek V3.1 +- Claude Sonnet 4.5 +- Gemini 3 +- Alibaba Qwen +- Kimi +- Grok 4.1 +**Planned** : Gemini, audio TTS, elevenlabs, OpenRouter, Groq, Dashscope +**Caractéristiques** : +- Spawn scene objects +- Control transformations and materials +- Generate blueprints, functions, variables +- Add components +- Run Python scripts +- UnrealMCP integration +- Automatic scene generation from AI + +#### Ludus AI +**Description** : Unreal Engine AI toolkit +**URL** : https://ludusengine.com/ +**Caractéristiques** : +- Instantly generate C++ code +- Generate 3D models +- Create functional Blueprints +- Answer UE5 questions +- Accelerate workflow + +### Automation Frameworks + +#### CLAUDIUS +**Description** : Claude's Unreal Direct Interface & Unified Scripting +**Forum** : https://forums.unrealengine.com/t/claudius-code-claudius-ai-powered-editor-automation-framework/2689084 +**Posted** : 3 weeks ago (early Jan 2026) +**Caractéristiques** : +- External tools control Unreal Editor +- Simple JSON commands +- Automation framework +**Type** : Framework + +--- + +## 📚 Libraries et SDKs + +### .uasset Parsing + +#### uasset-reader-js +**Description** : Read and extract information from .uasset files in JavaScript +**URL** : https://github.com/blueprintue/uasset-reader-js +**Language** : JavaScript/TypeScript +**Caractéristiques** : +- Pure JS implementation +- No native dependencies +- Read .uasset files +- Extract metadata +**Use Case** : ⭐ Perfect pour notre MCP TypeScript +**Status** : Active maintenance + +#### UAssetAPI +**Description** : Low-level .NET library for reading/writing UE game assets +**URL** : https://github.com/atenfyr/UAssetAPI +**Language** : C# / .NET +**Versions Supported** : UE ~4.13 to 5.3 +**Caractéristiques** : +- Read/write raw Kismet (Blueprint) bytecode +- JSON export/import with binary equality +- Low-level access +- Wide version support +**Companion Tool** : UAssetGUI + +#### UAssetGUI +**Description** : Tool for low-level examination and modification of UE assets by hand +**URL** : https://github.com/atenfyr/UAssetGUI +**Language** : C# / .NET +**Caractéristiques** : +- Graphical interface for UAssetAPI +- Command line support +- Export/import JSON +- Manual asset modification +**Workflow** : +1. Export JSON from UAssetGUI +2. Edit in text editor +3. Import back to .uasset + +#### uasset-rs +**Description** : Parsing of Unreal Engine asset files in Rust +**URL** : https://github.com/jorgenpt/uasset-rs +**Language** : Rust +**Caractéristiques** : +- Pure Rust implementation +- High performance +- No editor dependency +- Reason about assets programmatically +**Use Case** : Build tools without booting editor + +### Python APIs + +#### UnrealEnginePython +**Description** : Python integration for Unreal Engine +**URL** : https://github.com/20tab/UnrealEnginePython +**Tutorial** : https://github.com/20tab/UnrealEnginePython/blob/master/tutorials/YourFirstAutomatedPipeline.md +**Caractéristiques** : +- Automated pipelines +- Asset creation +- Editor scripting +**Status** : Legacy (UE4 focused) + +#### UnrealHelpers +**Description** : Python utilities for Unreal Engine 5.5 +**URL** : https://github.com/Short-Fuse-Games/UnrealHelpers +**Version UE** : 5.5 +**Caractéristiques** : +- Streamline common development tasks +- Programmatic interface to editor systems +- Asset management +- Level manipulation +- Material operations +- Blueprint creation +**Stats** : 76% faster iterations with automation +**Status** : Active, 2025-2026 + +### Model Context Protocol + +#### cpp-mcp +**Description** : Lightweight C++ MCP SDK +**URL** : https://github.com/hkr04/cpp-mcp +**Language** : C++ + +#### gopher-mcp +**Description** : MCP C++ SDK - Enterprise-grade implementation +**URL** : https://github.com/GopherSecurity/gopher-mcp +**Caractéristiques** : +- Enterprise-grade security +- Visibility and connectivity +- C++ implementation + +#### mcp-for-beginners +**Description** : Microsoft's official MCP curriculum +**URL** : https://github.com/microsoft/mcp-for-beginners +**Languages** : .NET, Java, TypeScript, JavaScript, Rust, Python +**Content** : +- Real-world examples +- Cross-language support +- Fundamentals to advanced +- Session setup to service orchestration + +--- + +## 🎓 Recherche Académique + +### MSR 2025 - Blueprint Parsing + +**Title** : "Under the Blueprints: Parsing Unreal Engine's Visual Scripting at Scale" +**Conference** : MSR 2025 (Mining Software Repositories) +**URL** : https://2025.msrconf.org/details/msr-2025-data-and-tool-showcase-track/31/Under-the-Blueprints-Parsing-Unreal-Engine-s-Visual-Scripting-at-Scale +**Paper** : https://softwareprocess.es/homepage/papers/2025-eng2025msr-unreal/ +**Authors** : Research team including Abram Hindle +**Dataset** : +- 335,753 Blueprint UAsset files +- 24,009 GitHub projects +- Custom extractors and parsers +**Contribution** : +- Parsed Blueprint graphs +- Mining visual scripting at scale +- Open dataset for research +**Year** : 2025 +**Impact** : ⭐ Premier dataset Blueprint académique à grande échelle + +### Skywork AI Analysis + +#### UE5-MCP Server Deep Dive +**URL** : https://skywork.ai/skypage/en/A-Deep-Dive-into-the-UE5-MCP-Server-Bridging-AI-and-Unreal-Engine/1972113994962538496 +**Content** : Technical analysis of MCP-UE5 integration +**Topics** : +- Architecture explanation +- Implementation details +- Use cases + +#### cc8887's MCP Server +**URL** : https://skywork.ai/skypage/en/unlocking-unreal-engine-ai-mcp-server/1977635071010598912 +**Content** : Unlocking Unreal Engine with AI +**Topics** : MCP server implementation analysis + +--- + +## 📖 Documentation Unreal + +### Official Documentation + +#### Exposing Gameplay to Blueprints (UE 4.27) +**URL** : https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/Blueprints/TechnicalGuide/ExtendingBlueprints +**Topics** : +- UPROPERTY macros +- UFUNCTION macros +- Blueprint exposure + +#### Exposing Gameplay to Blueprints (UE 5.7) +**URL** : https://dev.epicgames.com/documentation/en-us/unreal-engine/exposing-gameplay-elements-to-blueprints-visual-scripting-in-unreal-engine +**Topics** : Updated for UE5.7 + +#### C++ and Blueprints (UE 4.27) +**URL** : https://docs.unrealengine.com/4.27/en-US/ProgrammingAndScripting/ClassCreation/CodeAndBlueprints +**Topics** : +- Creating Blueprint from C++ class +- Extending C++ with Blueprints +- Mixed workflow + +#### CPP and Blueprints Example (UE 5.7) +**URL** : https://dev.epicgames.com/documentation/en-us/unreal-engine/cpp-and-blueprints-example +**Topics** : Practical examples + +#### Scripting Editor with Python (UE 4.27) +**URL** : https://docs.unrealengine.com/4.26/en-US/ProductionPipelines/ScriptingAndAutomation/Python +**Topics** : +- Python API basics +- Editor automation +- Asset pipelines + +#### Scripting Editor with Blueprints +**URL** : https://docs.unrealengine.com/4.26/en-US/ProductionPipelines/ScriptingAndAutomation/Blueprints +**Topics** : +- Editor Utility Blueprints +- Editor automation with BP + +#### FKismetEditorUtilities API +**URL** : https://docs.unrealengine.com/5.1/en-US/API/Editor/UnrealEd/Kismet2/FKismetEditorUtilities/CreateBlueprint/ +**Function** : CreateBlueprint +**Topics** : Programmatic Blueprint creation + +### Community Documentation + +#### Gamedev Guide - Custom Blueprints +**URL** : https://ikrima.dev/ue4guide/editor-extensions/custom-blueprints/custom-blueprint/ +**Topics** : +- Class diagram overview +- Custom Blueprint implementation + +#### Gamedev Guide - Useful Editor Functions +**URL** : https://ikrima.dev/ue4guide/editor-extensions/utility-helpers/useful-editor-functions/ +**Topics** : Editor extension helpers + +#### Gamedev Guide - Blueprint Compilation +**URL** : https://ikrima.dev/ue4guide/engine-programming/blueprints/bp-compiler-overview/ +**Topics** : +- FKismetCompilerContext +- Compilation process + +#### Unreal Community Wiki - Mixing Blueprints & C++ +**URL** : https://unrealcommunity.wiki/mixing-blueprints-and-cpp-8keheovh +**Topics** : Best practices for hybrid approach + +#### Unreal Community Wiki - Exposing Functions to Blueprint +**URL** : https://unrealcommunity.wiki/exposing-functions-to-blueprint-snrsgxew +**Topics** : UFUNCTION specifiers + +--- + +## 📝 Tutoriels et Guides + +### Blueprint → C++ Conversion + +#### Epic Official Course +**Title** : Converting Blueprint to C++ +**URL** : https://dev.epicgames.com/community/learning/courses/KJ/unreal-engine-converting-blueprint-to-c +**Provider** : Epic Games +**Level** : Intermediate +**Topics** : +- Manual conversion process +- Best practices +- Common patterns + +#### Chris McCole's Guide +**Title** : How to Convert a Blueprint into C++ +**URL** : https://www.chrismccole.com/blog/how-to-convert-a-blueprint-into-c +**Topics** : +- Step-by-step guide +- Practical examples + +#### AlienRenders Guide +**Title** : Converting Blueprint to C++ in Unreal Engine +**URL** : https://alienrenders.com/converting-blueprint-to-c-in-unreal-engine/ +**Topics** : Conversion techniques + +#### ContinueBreak Article +**Title** : Converting UE5 helicopter blueprints to C++ +**URL** : https://continuebreak.com/articles/blueprints-helicopter-to-cpp-ue5/ +**Topics** : Real-world example (helicopter physics) + +### Custom Blueprint Nodes + +#### Matt's Game Dev Notebook +**Title** : Reference Guide to Custom Blueprint Nodes +**URL** : https://unrealist.org/custom-blueprint-nodes/ +**Topics** : +- K2Node system +- Custom node creation +- ExpandNode function + +#### OlssonDev Blog +**Title** : Introduction to K2Node +**URL** : https://olssondev.github.io/2023-02-13-K2Nodes/ +**Date** : Feb 2023 +**Topics** : +- K2Node basics +- Kismet 2 history +- Node types + +#### Colory Games Tutorial +**Title** : Create Your Own Blueprint Node by Inheriting K2Node (1) Basic +**URL** : https://colory-games.net/site/en/create-your-own-blueprint-node-by-inheriting-k2node-1-basic-en/ +**Topics** : +- UE5 K2Node +- Step-by-step tutorial + +#### GameDev.net Tutorial +**Title** : Improving UE4 Blueprint Usability with Custom Nodes +**URL** : https://www.gamedev.net/tutorials/programming/engines-and-middleware/improving-ue4-blueprint-usability-with-custom-nodes-r5694/ +**Topics** : Advanced custom nodes + +#### Mikelis' Blog +**Title** : Designing Blueprint Function Nodes in C++ +**URL** : https://mikelis.net/designing-blueprint-function-nodes-in-c/ +**Topics** : Function node design patterns + +#### Lem Apperson Medium +**Title** : Learning Unreal: Creating Custom Blueprint Nodes in C++ +**URL** : https://medium.com/@lemapp09/learning-unreal-creating-custom-blueprint-nodes-in-c-3ddb54eaf254 +**Topics** : Beginner-friendly guide + +### Python Automation + +#### Joe Graf Medium +**Title** : Building UE4 Blueprint Function Libraries in Python +**URL** : https://medium.com/@joe.j.graf/building-ue4-blueprint-function-libraries-in-python-746ea9dd08b2 +**Topics** : +- Python class methods to Blueprint VM +- UE4 4.24+ feature +- No C++ required + +#### Johal.in Tutorial +**Title** : Unreal Engine Blueprints: Python Automation Scripts for Game Asset Pipelines +**URL** : https://johal.in/unreal-engine-blueprints-python-automation-scripts-for-game-asset-pipelines/ +**Year** : 2025 +**Topics** : +- Asset pipelines +- Blueprint manipulation +- AR/VR/Metaverse workflows + +#### Render Everything +**Title** : Automating Unreal Engine editor with Python +**URL** : https://www.rendereverything.com/automating-unreal-engine-editor-with-python/ +**Topics** : Editor automation basics + +### Editor Extensions + +#### Orfeas Eleftheriou - Custom Assets +**Title** : Creating Custom Editor Assets +**URL** : https://www.orfeasel.com/creating-custom-editor-assets/ +**Topics** : +- Asset factories +- Custom asset types + +#### Orfeas Eleftheriou - C++ Functions +**Title** : Exposing C++ functions to Blueprints +**URL** : https://www.orfeasel.com/exposing-cfunction-tobps/ +**Topics** : UFUNCTION specifiers + +#### Isara Tech - Editor Utility Widget +**Title** : UE4 - Programmatically starting an Editor Utility Widget +**URL** : https://isaratech.com/ue4-programmatically-starting-an-editor-utility-widget/ +**Topics** : Editor widgets automation + +#### VRealMatic - Working with Blueprint +**Title** : Working with Blueprint (BP) in Unreal Engine +**URL** : https://vrealmatic.com/unreal-engine/blueprint +**Topics** : +- FKismetEditorUtilities examples +- Complete code samples +- Error handling + +### Udemy Courses + +#### Python for Unreal Engine Editor Tools Scripting +**URL** : https://www.udemy.com/course/ue4python/ +**Topics** : Editor tools with Python + +#### Unreal Engine 5 Python Automation +**URL** : https://www.udemy.com/course/unreal-engine-5-python-automation/ +**Topics** : UE5 automation + +#### Unreal Engine Blueprint Automation +**URL** : https://www.udemy.com/course/unreal-engine-blueprint-automation/ +**Topics** : BP automation techniques + +--- + +## 🛠️ Outils Développement + +### IDE Integration + +#### JetBrains Rider + Claude Code +**Article** : My New Unreal Engine Setup: JetBrains Rider + Claude Code +**URL** : https://sharkpillow.com/post/rider-claude/ +**Benefits** : +- AI co-pilot +- Complete UE understanding (open source) +- C++ assistance + +### Code Generators + +#### Workik - Unreal Engine Code Generator +**URL** : https://workik.com/unreal-engine-code-generator +**Description** : FREE AI-Powered UE Code Generator +**Features** : +- Build & optimize games instantly +- AI-powered + +### Asset Tools + +#### FModel +**Description** : C# tool with sleek UI for .uasset files +**Library** : Uses CUE4Parse +**Purpose** : Asset exploration and extraction + +#### CUE4Parse +**Description** : C# library for parsing .uasset files +**Used By** : FModel +**Purpose** : Custom tool building + +#### UE Modding Tools +**URL** : https://github.com/Buckminsterfullerene02/UE-Modding-Tools +**Description** : Databank of every UE modding tool & guide +**Purpose** : Multi-game modding + +### Documentation Sites + +#### UAsset - Amicitia Wiki +**URL** : https://amicitia.miraheze.org/wiki/UAsset +**Content** : .uasset format documentation + +#### UASSET File Format Docs +**URL** : https://docs.fileformat.com/game/uasset/ +**Content** : File format specification + +#### Pecho's Mods - UAsset Modding +**URL** : https://pechomods.com/posts/uassetmodding/ +**Content** : Modding guide + +#### Unofficial Modding Guide - UAssetGUI +**URL** : https://unofficial-modding-guide.com/posts/uassetmodding/ +**Content** : Getting started with UAssetGUI + +#### Unofficial Modding Guide - UAsset Automation +**URL** : https://unofficial-modding-guide.com/posts/uassetautomation/ +**Content** : Automation workflows + +--- + +## 📊 Forum Discussions + +### Epic Developer Community + +#### Blueprint to C++ Translating Tool +**URL** : https://forums.unrealengine.com/t/blueprint-to-c-translating-tool/36394 +**Topics** : Community requests and discussions + +#### Blueprint to C++ Conversion Tool (2024) +**URL** : https://forums.unrealengine.com/t/blueprint-to-c-conversion-tool/2247622 +**Date** : Dec 2024 +**Topics** : Recent plugin that stubs out variables, functions, events + +#### Creating and editing Blueprint from C++ +**URL** : https://forums.unrealengine.com/t/creating-and-editing-blueprint-from-c/580589 +**Topics** : Programmatic BP creation challenges + +#### Create blueprint asset file with C++ +**URL** : https://forums.unrealengine.com/t/create-blueprint-asset-file-with-c/440868 +**Topics** : FKismetEditorUtilities usage + +#### FKismetEditorUtilities::CreateBlueprint issues +**URL** : https://forums.unrealengine.com/t/when-using-fkismeteditorutilities-createblueprint-parent-class-default-object-and-its-variables-are-not-what-i-set-it-to-be-what-am-i-doing-wrong/375708 +**Topics** : Common pitfalls and solutions + +#### Python - Access Blueprint contents +**URL** : https://forums.unrealengine.com/t/python-access-and-alter-contents-graphs-nodes-of-blueprints/155260 +**Topics** : Python BP manipulation + +#### Creating blueprint assets with Python +**URL** : https://forums.unrealengine.com/t/creating-blueprint-assets-hierarchies-with-python/115929 +**Topics** : Python asset creation + +#### Tool to parse C++ to Blueprint +**URL** : https://forums.unrealengine.com/t/tool-to-parse-and-convert-small-sections-of-c-to-blueprint/1338919 +**Topics** : Community need for C++→BP + +#### Is it possible to convert C++ to blueprints? +**URL** : https://forums.unrealengine.com/t/is-it-possible-to-convert-a-c-code-to-blueprints/422269 +**Topics** : Feasibility discussion + +#### What's the best AI for coding C++ in Unreal? +**URL** : https://forums.unrealengine.com/t/whats-the-best-ai-to-help-with-coding-c-in-unreal-chatgpt-or/1307271 +**Topics** : AI tools comparison + +#### Generating control rig blueprint graphs in C++ +**URL** : https://forums.unrealengine.com/t/generating-control-rig-blueprint-graphs-in-c/498272 +**Topics** : Advanced graph generation + +#### Bluepy and Blueprintcopilot +**URL** : https://forums.unrealengine.com/t/bluepy-and-blueprintcopilot/2023548 +**Topics** : AI Blueprint tools discussion + +#### Ultimate Blueprint Generator Discussion +**URL** : https://forums.unrealengine.com/t/ultimate-blueprint-generator-the-ai-co-pilot-for-unreal-engine/2618922 +**Topics** : Commercial tool feedback + +### AnswerHub (Legacy) + +#### How to generate Blueprint using C++? +**URL** : https://answers.unrealengine.com/questions/702770/how-to-generate-a-blueprint-blueprintgraph-using-c.html +**Topics** : Historical discussions + +--- + +## 🌐 Community Resources + +### MCP Directories + +#### PulseMCP - Unreal Engine MCP Server +**URL** : https://www.pulsemcp.com/servers/runreal-unreal-engine +**Listing** : runreal server + +#### LobeHub MCP Servers + +**UnrealMCP Plugin** +**URL** : https://lobehub.com/mcp/alexkissijr-unrealmcp + +**Unreal Engine Generative AI Support Plugin** +**URL** : https://lobehub.com/mcp/prajwalshettydev-unrealgenaisupport + +**Model Context Protocol for Unreal Engine** +**URL** : https://lobehub.com/mcp/nootest-unreal-mcp + +#### MCP.so Server Directory + +**Model Context Protocol for Unreal Engine** +**URL** : https://mcp.so/server/unreal-mcp/chongdashu + +#### Glama + +**Unreal Engine Code Analyzer MCP Server** +**URL** : https://glama.ai/mcp/servers/@ayeletstudioindia/unreal-analyzer-mcp + +#### Awesome MCP Servers + +**UnrealMCP Plugin** +**URL** : https://mcpservers.org/servers/AlexKissiJr/UnrealMCP + +#### MCPlane + +**Unreal Engine Generative AI & MCP Plugin** +**URL** : https://www.mcplane.com/mcp_servers/unreal-gen-ai-support + +#### Apidog Blog + +**How to Use Unreal Engine MCP Server** +**URL** : https://apidog.com/blog/unreal-engine-mcp-server/ +**Content** : Tutorial and guide + +### Learning Platforms + +#### Toxigon + +**Title** : Unlocking UE5 Blueprint AI Generators: Transforming Game Development +**URL** : https://toxigon.com/ue5-blueprint-ai-generator +**Topics** : AI generators overview + +### GitHub Collections + +#### Learning Unreal Engine +**URL** : https://github.com/ibbles/LearningUnrealEngine/blob/master/Creating new asset types.md +**Content** : Creating new asset types + +#### Baked Bits Blog +**URL** : https://bakedbits.dev/posts/unreal-data-assets-from-blueprints/ +**Topics** : Creating Data Assets from Blueprints + +#### Artstation Tutorial +**URL** : https://www.artstation.com/fristet/blog/ae8q/blueprint-tutorial-create-data-driven-prefabs-script-on-unreal-engine-part-1-create-assets +**Topics** : Blueprint tutorial - data driven prefabs + +--- + +## 🔍 Statistiques et Insights + +### Adoption Metrics + +- **5M+ Unreal developers** worldwide (2025) +- **76% faster iterations** with Python automation (reported) +- **63% reduction in iteration time** with MCP vs traditional workflows +- **AAA studios** actively using MCP integrations +- **10+ active MCP projects** for Unreal (Jan 2026) + +### Market Insights + +**Revenue Potential** +- €10k-50k per project (higher than Unity) +- Enterprise focus +- AAA studio budgets + +**Pain Points Identified** +- Blueprint performance bottlenecks +- Manual BP→C++ conversion tedious (hours) +- No automated optimization tools +- Learning curve for C++ steep +- Network code complexity + +**Trends** +- AR/VR/Metaverse demanding thousands of assets +- Automated pipelines essential for scale +- AI-assisted development mainstream +- Python automation adoption increasing +- MCP protocol gaining traction + +--- + +## 📌 Articles Clés + +### General MCP + +#### Model Context Protocol - Wikipedia +**URL** : https://en.wikipedia.org/wiki/Model_Context_Protocol +**Content** : MCP overview + +#### What is MCP? +**URL** : https://modelcontextprotocol.io/ +**Content** : Official MCP introduction + +#### OpenAI Developers - MCP +**URL** : https://developers.openai.com/codex/mcp/ +**Content** : OpenAI's MCP documentation + +#### MCP Docs +**URL** : https://modelcontextprotocol.info/docs/ +**Content** : Documentation hub + +#### Nearform - Implementing MCP +**URL** : https://nearform.com/digital-community/implementing-model-context-protocol-mcp-tips-tricks-and-pitfalls/ +**Content** : Tips, tricks and pitfalls + +#### GitHub Discussion - Code execution with MCP +**URL** : https://github.com/modelcontextprotocol/modelcontextprotocol/discussions/1780 +**Topics** : Building efficient agents + +--- + +## 📝 Notes de Recherche + +### Découvertes Clés + +1. **Aucun MCP ne fait de génération C++** + - Tous les MCP existants font du runtime control + - Niche complètement ouverte + +2. **Blueprint parsing bien établi** + - Multiple libraries disponibles + - uasset-reader-js parfait pour TypeScript + - Dataset académique disponible (MSR 2025) + +3. **Génération Blueprint possible** + - Bluepy prouve que c'est faisable + - Python API puissante + - FKismetEditorUtilities bien documenté + +4. **Round-trip techniquement faisable** + - NodeToCode fait BP→C++ + - Bluepy fait description→BP + - Notre innovation : combiner les deux + +5. **Marché actif et demandeur** + - 10+ projets MCP en développement actif + - Forums pleins de demandes pour ces outils + - Plugins commerciaux viables + +### Gaps Identifiés + +❌ **Pas de génération C++ production-ready** +❌ **Pas de workflow bidirectionnel** +❌ **Pas d'optimisation automatique** +❌ **Pas d'analyse statique Blueprint** +❌ **Pas de support multiplayer avancé** + +✅ **Notre opportunité unique** + +--- + +## 🎯 Priorités pour Notre Projet + +### Must Use + +1. **uasset-reader-js** - Blueprint parsing +2. **Claude API** - Code generation +3. **MCP SDK** - Protocol +4. **Python Remote Execution** - Blueprint generation (optional) + +### Should Investigate + +1. **UAssetAPI** - Fallback parser +2. **NodeToCode patterns** - BP→C++ inspiration +3. **Bluepy approach** - BP generation insights +4. **MSR 2025 dataset** - Test cases + +### Nice to Have + +1. **UnrealHelpers** - Python utilities reference +2. **Custom K2Node** - Advanced BP nodes +3. **Control Rig** - Animation support + +--- + +**Dernière mise à jour** : 2026-01-20 +**Maintenu par** : Alexis Trouvé +**Statut** : Living Document diff --git a/docs/SPEC.md b/docs/SPEC.md index c81d4fd..d275d29 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1,395 +1,300 @@ -# Unreal Engine MCP Server - Technical Specification +# Unreal MCP Server - Specification -**Project** : AI-powered Unreal Engine development via MCP -**Target** : Unreal developers, AAA studios, technical designers -**Stack** : TypeScript + FastMCP + Claude API -**Timeline** : 2-3 weeks MVP +## Overview + +MCP Server Python pour Unreal Engine combinant : +1. **Runtime Control** - Manipulation scene en temps reel +2. **Project Intelligence** - Analyse codebase et assets +3. **Debug Tools** - Logs, crashes, profiling --- -## 🎯 What is it? +## MCP Tools -MCP server enabling Claude Desktop to generate Unreal C++ code, Blueprint logic descriptions, debug issues, and optimize performance using AI. +### Project Intelligence -**Key Features** : -✅ Generate Unreal C++ classes (.h + .cpp) from natural language -✅ Blueprint → C++ conversion for performance -✅ Create complete gameplay systems (weapons, abilities, inventory) -✅ Debug Unreal code with AI assistance -✅ Performance analysis (profiling, optimization) -✅ Network replication code generation -✅ Animation Blueprint logic +#### `get_spawnable_classes` +Liste toutes les classes spawnables du projet. ---- +```python +# Input +{"filter": "blueprint" | "native" | "all"} # optional -## 🔧 MCP Tools - -### C++ Generation - -**create_unreal_class** - Generate UE C++ class -```typescript -Input: "Create ACharacter subclass with sprint and crouch" -Output: -- MyCharacter.h (with UCLASS, UPROPERTY, UFUNCTION macros) -- MyCharacter.cpp (implementation) -``` - -**generate_gameplay_system** - Complete systems -```typescript -Input: "Create weapon system with ammo, reload, and firing modes" -Output: -- AWeapon.h/.cpp (weapon actor) -- UWeaponComponent.h/.cpp (component for characters) -- FWeaponData.h (data struct) -- Network replication setup -``` - -**generate_actor_component** - UActorComponent classes -```typescript -Input: "Health component with damage, healing, and death" -Output: UHealthComponent with proper UE architecture -``` - ---- - -### Blueprint Tools - -**blueprint_to_cpp** - Convert BP logic to C++ -```typescript -Input: Description of Blueprint logic or visual graph -Output: Optimized C++ equivalent with notes on performance gains -``` - -**generate_blueprint_library** - Blueprint Function Library -```typescript -Input: "Math utilities for gameplay (lerp, easing, etc.)" -Output: UBlueprintFunctionLibrary with static functions callable from BP -``` - ---- - -### AI-Powered Tools - -**debug_unreal_code** - AI debugging -```typescript -Input: C++ code + crash logs + callstack -Output: Root cause + fixed code + prevention tips -``` - -**optimize_unreal_performance** - Performance analysis -```typescript -Input: Unreal C++ code + optional profiler data -Analysis: -- Tick() function overhead -- Blueprint vs C++ performance comparison -- Rendering optimization suggestions -- Memory leak detection -- Thread safety issues -- Network replication bandwidth -Output: Optimized code + profiling recommendations -``` - -**analyze_crash_dump** - Crash analysis -```typescript -Input: Crash log + callstack -Output: Probable causes + fix suggestions -``` - ---- - -### Network & Multiplayer - -**generate_replicated_actor** - Network replication -```typescript -Input: "Replicated projectile with client prediction" -Output: -- C++ class with UPROPERTY(Replicated) -- GetLifetimeReplicatedProps implementation -- Client-side prediction code -- Server RPC functions -``` - -**optimize_network_bandwidth** - Replication analysis -```typescript -Input: Replicated class code -Output: Bandwidth optimization suggestions (relevancy, replication conditions) -``` - ---- - -### Advanced Tools - -**generate_animation_system** - Animation logic -```typescript -Input: "Animation system with blending and state machines" -Output: Animation Blueprint logic description + C++ notify classes -``` - -**generate_subsystem** - UE Subsystem -```typescript -Input: "Game instance subsystem for save/load" -Output: UGameInstanceSubsystem class with proper lifecycle -``` - -**generate_slate_ui** - Slate UI code -```typescript -Input: "Custom editor widget for level design" -Output: Slate C++ code for editor customization -``` - ---- - -## 🏗️ Architecture - -``` -Unreal MCP Server - ↓ Claude API -Generate C++ files locally - ↓ -User adds to Unreal project - ↓ -Compile in Unreal Editor -``` - -**Note** : No direct Unreal Editor integration - generates files only - ---- - -## 💼 Use Cases - -### Gameplay Programming -- Weapon systems -- Ability systems -- AI controllers -- Inventory systems - -### Performance Optimization -- Blueprint → C++ migration -- Reduce tick overhead -- Network optimization -- Memory profiling - -### Multiplayer Development -- Replicated actors -- Server RPCs -- Client prediction -- Authority validation - -### Technical Design -- Custom editor tools -- Asset pipeline automation -- Blueprint libraries - ---- - -## 📊 Target Market - -- **5M+ Unreal developers** worldwide -- AAA game studios -- Indie teams using Unreal -- Technical designers -- Multiplayer game developers -- VR/AR experiences (Unreal is strong here) - -**Revenue Potential** : €10k-50k/project (higher than Unity, more enterprise) - ---- - -## 🚀 Implementation Timeline - -**Week 1** : Core C++ generation (classes, components, basic systems) -**Week 2** : Blueprint tools (BP→C++, libraries) -**Week 3** : Advanced (networking, optimization, debugging) - -**MVP** : 2-3 weeks - ---- - -## 📝 Example Output Quality - -### Input -``` -"Create a replicated weapon with firing, ammo, and reload for multiplayer FPS" -``` - -### Output -```cpp -// Weapon.h -#pragma once - -#include "CoreMinimal.h" -#include "GameFramework/Actor.h" -#include "Weapon.generated.h" - -UCLASS() -class MYGAME_API AWeapon : public AActor +# Output { - GENERATED_BODY() - -public: - AWeapon(); - - virtual void GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const override; - - UFUNCTION(BlueprintCallable, Category = "Weapon") - void Fire(); - - UFUNCTION(BlueprintCallable, Category = "Weapon") - void Reload(); - -protected: - virtual void BeginPlay() override; - - UFUNCTION(Server, Reliable, WithValidation) - void ServerFire(); - - UFUNCTION(NetMulticast, Unreliable) - void MulticastPlayFireEffects(); - - UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Weapon") - int32 MaxAmmo = 30; - - UPROPERTY(ReplicatedUsing = OnRep_CurrentAmmo, BlueprintReadOnly, Category = "Weapon") - int32 CurrentAmmo; - - UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Weapon") - float FireRate = 0.1f; - - UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Weapon") - float ReloadTime = 2.0f; - -private: - UFUNCTION() - void OnRep_CurrentAmmo(); - - float LastFireTime = 0.0f; - bool bIsReloading = false; - - void ConsumeAmmo(); - bool CanFire() const; -}; - -// Weapon.cpp -#include "Weapon.h" -#include "Net/UnrealNetwork.h" -#include "TimerManager.h" - -AWeapon::AWeapon() -{ - PrimaryActorTick.bCanEverTick = false; - bReplicates = true; - CurrentAmmo = MaxAmmo; -} - -void AWeapon::GetLifetimeReplicatedProps(TArray& OutLifetimeProps) const -{ - Super::GetLifetimeReplicatedProps(OutLifetimeProps); - - DOREPLIFETIME_CONDITION(AWeapon, CurrentAmmo, COND_OwnerOnly); -} - -void AWeapon::Fire() -{ - if (!CanFire()) return; - - if (HasAuthority()) - { - ServerFire(); - } - else - { - ServerFire(); - // Client prediction: play effects immediately - MulticastPlayFireEffects(); - } -} - -bool AWeapon::ServerFire_Validate() -{ - return CanFire(); -} - -void AWeapon::ServerFire_Implementation() -{ - if (!CanFire()) return; - - ConsumeAmmo(); - LastFireTime = GetWorld()->GetTimeSeconds(); - - // Perform hit detection - // ... - - MulticastPlayFireEffects(); -} - -void AWeapon::MulticastPlayFireEffects_Implementation() -{ - // Play muzzle flash, sound, etc. - // ... -} - -void AWeapon::Reload() -{ - if (bIsReloading || CurrentAmmo == MaxAmmo) return; - - bIsReloading = true; - - FTimerHandle ReloadTimer; - GetWorld()->GetTimerManager().SetTimer(ReloadTimer, [this]() - { - CurrentAmmo = MaxAmmo; - bIsReloading = false; - }, ReloadTime, false); -} - -void AWeapon::ConsumeAmmo() -{ - CurrentAmmo = FMath::Max(0, CurrentAmmo - 1); -} - -bool AWeapon::CanFire() const -{ - const float CurrentTime = GetWorld()->GetTimeSeconds(); - return !bIsReloading - && CurrentAmmo > 0 - && (CurrentTime - LastFireTime) >= FireRate; -} - -void AWeapon::OnRep_CurrentAmmo() -{ - // Update UI - // ... + "native_actors": [{"class_name": "ACharacter", "module": "Engine"}], + "blueprint_actors": [{"name": "BP_Enemy", "path": "/Game/Enemies/BP_Enemy"}], + "components": [{"name": "UHealthComponent", "is_custom": True}] } ``` -**Quality** : Production-ready multiplayer code with proper replication, validation, client prediction +**Implementation:** +- Editor ouvert: API `unreal` directe +- Editor ferme: Scan Content/ + parse .uasset + +#### `get_project_assets` +Inventaire des assets par type. + +```python +# Output +{ + "blueprints": 127, + "materials": 45, + "by_folder": {"Enemies": ["BP_Enemy"], "Weapons": ["BP_Rifle"]} +} +``` + +#### `scan_cpp_classes` +Parse les classes C++ du projet. + +```python +# Output +[{ + "name": "AWeapon", + "file": "Source/MyGame/Weapon.h", + "parent": "AActor", + "is_blueprintable": True +}] +``` --- -## 🎯 Unique Value +### Scene Manipulation -- ✅ Proper Unreal macros (UCLASS, UPROPERTY, UFUNCTION) -- ✅ Network replication out of the box -- ✅ Client prediction patterns -- ✅ Authority validation -- ✅ Follows Epic coding standards -- ✅ Optimized for performance -- ✅ Both .h and .cpp generated +#### `spawn_actor` +Spawn un acteur dans la scene. + +```python +# Input +{ + "class_name": "BP_Enemy", + "location": [0, 0, 100], + "rotation": [0, 0, 0], # optional + "properties": {"Health": 100} # optional +} + +# Output +{"success": True, "actor_id": "BP_Enemy_3", "location": [0, 0, 100]} +``` + +#### `get_scene_hierarchy` +Retourne l'arbre des acteurs. + +```python +# Output +{ + "level": "MainLevel", + "actors": [{ + "id": "actor_1234", + "class": "BP_Enemy", + "location": [120, 450, 100], + "components": ["HealthComponent"] + }] +} +``` + +#### `modify_actor_transform` +Modifie position/rotation d'un acteur. + +```python +# Input +{"actor_id": "actor_1234", "location": [200, 500, 150], "rotation": [0, 90, 0]} +``` --- -## 💡 Why Separate from Unity? +### Debug & Profiling -**Completely different ecosystems** : -- Unity = C#, .NET, MonoBehaviour, ScriptableObject -- Unreal = C++, Blueprints, UCLASS/UPROPERTY macros, replication +#### `get_console_logs` +Recupere les logs Unreal. -**Different markets** : -- Unity = Indie, mobile, smaller teams -- Unreal = AAA, high-end graphics, larger budgets +```python +# Input +{"filter": "Error" | "Warning" | "All", "limit": 100} # optional -**Separate projects = Better focus** +# Output +[ + {"timestamp": "14:23:47", "level": "Error", "message": "Null pointer in Weapon.cpp:145"} +] +``` + +#### `analyze_crash_dump` +Analyse un crash log. + +```python +# Input +{"crash_log": "...", "callstack": "..."} # callstack optional + +# Output +{ + "root_cause": "Null pointer dereference", + "file": "Weapon.cpp", + "line": 145, + "fix_suggestion": "if (CurrentTarget && IsValid(CurrentTarget)) {...}" +} +``` + +#### `profile_blueprint` +Profile les performances d'un Blueprint. + +```python +# Input +{"blueprint_path": "BP_AI.uasset"} + +# Output +{ + "avg_tick_time_ms": 2.3, + "hotspots": [{"node": "Event Tick", "cost_ms": 1.8, "percentage": 78}], + "optimization_potential": "HIGH" +} +``` --- -**Last Updated** : 2026-01-19 +### Blueprint Operations + +#### `read_blueprint` +Parse un fichier .uasset. + +```python +# Input +{"file_path": "Content/Blueprints/BP_Character.uasset"} + +# Output +{ + "class_name": "BP_Character", + "parent": "ACharacter", + "variables": [{"name": "Health", "type": "float", "default": 100}], + "functions": ["Fire", "Reload"], + "events": ["OnDeath"] +} +``` + +#### `create_blueprint_from_cpp` +Cree un Blueprint depuis une classe C++. + +```python +# Input +{ + "cpp_class": "AWeapon", + "blueprint_name": "BP_Weapon", + "output_path": "/Game/Weapons", + "exposed_properties": ["MaxAmmo", "FireRate"] +} + +# Output +{"success": True, "blueprint_path": "/Game/Weapons/BP_Weapon"} +``` + +#### `execute_python_script` +Execute un script Python dans l'editeur. + +```python +# Input +{"script": "import unreal; unreal.log('Hello')"} +# ou +{"script_path": "scripts/create_actor.py"} +``` + +--- + +## Unreal Connection + +### Mode 1: Direct (dans Unreal) +Quand le serveur MCP tourne dans le contexte Unreal Python. + +```python +import unreal + +# Accès direct +actors = unreal.EditorLevelLibrary.get_all_level_actors() +``` + +### Mode 2: Remote Execution (externe) +Quand le serveur MCP tourne en externe, connection via port 9998. + +```python +class UnrealConnection: + def __init__(self, host: str = "localhost", port: int = 9998): + ... + + def connect(self) -> bool: + ... + + def execute(self, script: str) -> Any: + ... + + def is_connected(self) -> bool: + ... + + def disconnect(self) -> None: + ... +``` + +**Prerequis Unreal:** +- Plugin "Python Editor Script Plugin" active +- Remote Execution enable dans Project Settings + +### Mode 3: Fallback (éditeur fermé) +- Scan fichiers Content/ +- Parse .uasset avec unreal_asset ou UAssetAPI +- Generation scripts Python pour execution ulterieure + +--- + +## Workflow Examples + +### Spawn enemies in circle +``` +User: "Spawn 10 BP_Enemy in a circle" + +1. get_spawnable_classes() -> trouve BP_Enemy +2. Loop: spawn_actor() avec positions calculees +3. Return: "10 enemies spawned" +``` + +### Debug crash +``` +User: "Game crashes when shooting" + +1. get_console_logs(filter="Error") +2. analyze_crash_dump(crash_log) +3. Return fix suggestion +``` + +### Create Blueprint from C++ +``` +User: "Create BP from AWeapon class" + +1. scan_cpp_classes() -> trouve AWeapon +2. create_blueprint_from_cpp() +3. Return: "BP_Weapon created at /Game/Weapons" +``` + +--- + +## Response Format + +Tous les tools retournent : + +```python +# Success +{"success": True, "data": {...}} + +# Error +{"success": False, "error": "Description", "code": "ERROR_CODE"} +``` + +--- + +## Environment Variables + +```bash +UE_PROJECT_PATH=/path/to/MyProject +UE_PYTHON_PORT=9998 +LOG_LEVEL=debug +``` + +--- + +*Last update: 2026-01-20* diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..4f0d879 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,613 @@ +# Unreal MCP Server - User Guide + +Complete guide for using Unreal MCP Server with Claude Code. + +--- + +## Table of Contents + +1. [Installation](#installation) +2. [Configuration](#configuration) +3. [MCP Tools](#mcp-tools) +4. [Blueprint Workflow Skill](#blueprint-workflow-skill) +5. [Troubleshooting](#troubleshooting) + +--- + +## Installation + +### Prerequisites + +- Python 3.11 or higher +- Claude Code +- Unreal Engine 5.3+ (optional, for runtime features) + +### Install from source + +```bash +# Clone the repository +git clone https://github.com/AlexisTrouve/unreal-mcp.git +cd unreal-mcp + +# Install the package +pip install -e . + +# Or with development dependencies +pip install -e ".[dev]" +``` + +### Verify installation + +```bash +# Run tests +pytest + +# Check if the module is importable +python -c "import unreal_mcp; print(unreal_mcp.__version__)" +``` + +--- + +## Configuration + +### Claude Code Setup + +The repository includes a `.mcp.json` file that configures Claude Code automatically. + +Edit `.mcp.json` to set your Unreal project path: + +```json +{ + "mcpServers": { + "unreal-mcp": { + "command": "python", + "args": ["-m", "unreal_mcp.server"], + "env": { + "UE_PROJECT_PATH": "C:/Path/To/Your/UnrealProject", + "UE_COMMAND_PORT": "6776", + "UE_MULTICAST_PORT": "6766" + } + } + } +} +``` + +After editing, restart Claude Code or run `/mcp` to reload MCP servers. + +### Environment Variables + +Create a `.env` file in the project root (copy from `.env.example`): + +| Variable | Default | Description | +|----------|---------|-------------| +| `UE_PROJECT_PATH` | - | Path to your Unreal project folder | +| `UE_COMMAND_PORT` | 6776 | TCP port for commands | +| `UE_MULTICAST_GROUP` | 239.0.0.1 | Multicast group for discovery | +| `UE_MULTICAST_PORT` | 6766 | Multicast port for discovery | +| `LOG_LEVEL` | INFO | Logging level | + +### Unreal Editor Setup (Optional) + +For runtime features (spawn actors, execute scripts), enable Python Remote Execution in Unreal: + +1. Open your project in Unreal Editor +2. Go to **Edit > Plugins** +3. Enable **Python Editor Script Plugin** +4. Go to **Edit > Project Settings > Python** +5. Enable **Enable Remote Execution** +6. Set port to match your config (default: 6776) +7. Restart Unreal Editor + +--- + +## MCP Tools + +### Project Intelligence + +#### `get_spawnable_classes` + +List all spawnable classes in the project. + +**Input:** +```json +{ + "filter": "all" // "all", "blueprint", or "native" +} +``` + +**Output:** +```json +{ + "native_actors": [{"class_name": "ACharacter", "module": "Engine"}], + "blueprint_actors": [{"name": "BP_Enemy", "path": "/Game/Enemies/BP_Enemy"}] +} +``` + +**Example usage:** +> "What classes can I spawn in this project?" + +--- + +#### `get_project_assets` + +Get inventory of all project assets. + +**Input:** +```json +{ + "asset_type": "Blueprint" // optional filter +} +``` + +**Output:** +```json +{ + "total": 127, + "by_type": {"Blueprint": 45, "Material": 30, "Texture": 52}, + "by_folder": {"Characters": ["BP_Player", "BP_Enemy"]} +} +``` + +**Example usage:** +> "How many blueprints are in my project?" + +--- + +#### `scan_cpp_classes` + +Scan C++ classes in the Source folder. + +**Input:** +```json +{ + "filter_blueprintable": true // only Blueprintable classes +} +``` + +**Output:** +```json +[{ + "name": "AWeapon", + "file": "Source/MyGame/Weapon.h", + "parent": "AActor", + "is_blueprintable": true +}] +``` + +**Example usage:** +> "Find all Blueprintable C++ classes in my project" + +--- + +### Scene Manipulation + +#### `spawn_actor` + +Spawn an actor in the current level. + +**Input:** +```json +{ + "class_name": "BP_Enemy", + "location": [0, 0, 100], + "rotation": [0, 90, 0], + "properties": {"Health": 100} +} +``` + +**Output:** +```json +{ + "success": true, + "actor_id": "BP_Enemy_3", + "location": [0, 0, 100] +} +``` + +**Example usage:** +> "Spawn 5 BP_Enemy actors in a circle around the player" + +--- + +#### `get_scene_hierarchy` + +Get the hierarchy of actors in the current level. + +**Input:** +```json +{ + "include_components": true +} +``` + +**Output:** +```json +{ + "level": "MainLevel", + "actors": [{ + "id": "BP_Enemy_1", + "class": "BP_Enemy", + "location": [100, 200, 0], + "components": ["MeshComponent", "HealthComponent"] + }] +} +``` + +**Example usage:** +> "What actors are in the current level?" + +--- + +#### `modify_actor_transform` + +Modify an actor's position, rotation, or scale. + +**Input:** +```json +{ + "actor_id": "BP_Enemy_1", + "location": [500, 0, 0], + "rotation": [0, 180, 0], + "scale": [2, 2, 2] +} +``` + +**Example usage:** +> "Move BP_Enemy_1 to position 500, 0, 0" + +--- + +### Debug & Profiling + +#### `get_console_logs` + +Get Unreal Engine console logs. + +**Input:** +```json +{ + "filter": "Error", // "All", "Error", "Warning" + "limit": 50 +} +``` + +**Example usage:** +> "Show me the recent error logs" + +--- + +#### `analyze_crash_dump` + +Analyze a crash dump and suggest fixes. + +**Input:** +```json +{ + "crash_log": "Access violation at 0x00000...", + "callstack": "Weapon.cpp:145..." +} +``` + +**Output:** +```json +{ + "root_cause": "Null pointer dereference", + "file": "Weapon.cpp", + "line": 145, + "fix_suggestion": "Add null check: if (Target && IsValid(Target)) {...}" +} +``` + +**Example usage:** +> "Analyze this crash: [paste crash log]" + +--- + +#### `profile_blueprint` + +Profile a Blueprint's performance. + +**Input:** +```json +{ + "blueprint_path": "/Game/AI/BP_AIController" +} +``` + +**Output:** +```json +{ + "avg_tick_time_ms": 2.3, + "hotspots": [{"node": "Event Tick", "cost_ms": 1.8}], + "optimization_potential": "HIGH" +} +``` + +**Example usage:** +> "Profile BP_AIController for performance issues" + +--- + +### Blueprint Operations + +#### `read_blueprint` + +Parse a Blueprint .uasset file. + +**Input:** +```json +{ + "file_path": "/Game/Characters/BP_Player" +} +``` + +**Output:** +```json +{ + "class_name": "BP_Player", + "parent_class": "ACharacter", + "variables": ["Health", "Speed"], + "functions": ["Fire", "Reload"], + "events": ["OnDeath"] +} +``` + +**Example usage:** +> "What variables and functions does BP_Player have?" + +--- + +#### `create_blueprint_from_cpp` + +Create a Blueprint from a C++ class. + +**Input:** +```json +{ + "cpp_class": "AWeapon", + "blueprint_name": "BP_Weapon", + "output_path": "/Game/Weapons" +} +``` + +**Output:** +```json +{ + "success": true, + "blueprint_path": "/Game/Weapons/BP_Weapon" +} +``` + +**Example usage:** +> "Create a Blueprint from my AWeapon C++ class" + +--- + +#### `execute_python_script` + +Execute a Python script in Unreal Editor. + +**Input:** +```json +{ + "script": "import unreal; print(unreal.EditorLevelLibrary.get_all_level_actors())" +} +``` + +**Example usage:** +> "Run this Python script in Unreal: [script]" + +--- + +## Blueprint Workflow Skill + +The `/blueprint-workflow` skill provides high-level Blueprint operations. + +### Commands + +#### `/blueprint-workflow analyze ` + +Analyze a Blueprint for performance issues. + +``` +/blueprint-workflow analyze BP_Enemy.uasset +``` + +**Output:** +- Performance issues (tick cost, casts, etc.) +- Optimization suggestions +- Complexity score + +--- + +#### `/blueprint-workflow bp-to-cpp ` + +Convert a Blueprint to C++ code. + +``` +/blueprint-workflow bp-to-cpp BP_Enemy.uasset +``` + +**Output:** +- Header file (.h) +- Implementation file (.cpp) +- Conversion notes + +--- + +#### `/blueprint-workflow cpp-to-bp [name] [path]` + +Generate a Blueprint from a C++ class. + +``` +/blueprint-workflow cpp-to-bp AWeapon BP_Weapon /Game/Weapons +``` + +**Output:** +- Blueprint created in Unreal +- Exposed properties list +- Callable functions list + +--- + +#### `/blueprint-workflow transform ""` + +Transform a Blueprint with AI-powered modifications. + +``` +/blueprint-workflow transform BP_Character.uasset "add dash ability with cooldown" +``` + +**Output:** +- Modified Blueprint +- New variables/functions added +- Implementation details + +--- + +#### `/blueprint-workflow optimize ` + +Automatically optimize a Blueprint. + +``` +/blueprint-workflow optimize BP_AIController.uasset +``` + +**Output:** +- Performance before/after +- Optimizations applied +- Generated C++ (if beneficial) + +--- + +## Troubleshooting + +### MCP Server not appearing in Claude Code + +1. Check `.mcp.json` is in the project root +2. Verify Python path: `which python` or `where python` +3. Run `/mcp` in Claude Code to reload servers +4. Check Claude Code logs for errors + +### "Not connected to Unreal Editor" + +The server works in two modes: + +1. **Online mode**: Connects to running Unreal Editor +2. **Offline mode**: Works with project files only + +For online mode: +- Ensure Unreal Editor is running +- Enable Python Remote Execution in Project Settings +- Check port matches config (default: 6776) +- Firewall may block UDP multicast + +### "No Unreal nodes discovered" + +1. Check Unreal Editor is running +2. Verify Python Editor Script Plugin is enabled +3. Check Remote Execution is enabled in Project Settings +4. Ensure ports match (6766 for discovery, 6776 for commands) +5. Try restarting Unreal Editor + +### "Parse error" on .uasset files + +- File may be from a different Unreal version +- File may be corrupted +- Some asset types aren't fully supported + +### Python import errors + +```bash +# Reinstall the package +pip uninstall unreal-mcp +pip install -e . +``` + +### Port conflicts + +Change ports in `.env` or `.mcp.json`: +``` +UE_COMMAND_PORT=6777 +UE_MULTICAST_PORT=6767 +``` + +Remember to update Unreal Project Settings to match. + +--- + +## Examples + +### Example 1: Debug a crash + +``` +User: My game crashes when I shoot. Here's the log: +[paste crash log] + +Claude: [Uses analyze_crash_dump] +The crash is a null pointer dereference in Weapon.cpp:145. +The CurrentTarget variable is null when firing. + +Suggested fix: +if (CurrentTarget && IsValid(CurrentTarget)) +{ + // Safe to use CurrentTarget +} +``` + +### Example 2: Optimize a Blueprint + +``` +User: /blueprint-workflow analyze BP_AIController + +Claude: [Analyzes Blueprint] + +## Performance Analysis: BP_AIController + +### Issues Found +🔴 HIGH: Event Tick with 23 operations/frame + → Move pathfinding to timer-based updates + +🟡 MEDIUM: 5 Cast nodes found + → Replace with Blueprint Interfaces + +### Recommendation +Convert to C++ for +180% performance gain. +``` + +### Example 3: Generate code + +``` +User: Create a C++ health component with damage and healing + +Claude: [Generates HealthComponent.h and HealthComponent.cpp] + +// HealthComponent.h +UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent)) +class MYGAME_API UHealthComponent : public UActorComponent +{ + GENERATED_BODY() + +public: + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Health") + float MaxHealth = 100.0f; + + UFUNCTION(BlueprintCallable, Category = "Health") + void ApplyDamage(float Damage); + + UFUNCTION(BlueprintCallable, Category = "Health") + void Heal(float Amount); + ... +}; +``` + +--- + +## Support + +- GitHub Issues: https://github.com/AlexisTrouve/unreal-mcp/issues +- Documentation: https://github.com/AlexisTrouve/unreal-mcp/docs + +--- + +*Last updated: 2026-01-20* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..599432b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,81 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "unreal-mcp" +version = "0.1.0" +description = "MCP server for Unreal Engine - Runtime control, Project Intelligence, Debug tools" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +authors = [ + { name = "Alexis Trouve" } +] +keywords = ["unreal", "mcp", "model-context-protocol", "game-development", "blueprint"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Games/Entertainment", + "Topic :: Software Development :: Code Generators", +] + +dependencies = [ + "mcp>=1.0.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.1.0", + "mypy>=1.8.0", +] + +[project.scripts] +unreal-mcp = "unreal_mcp.server:main" + +[project.urls] +Homepage = "https://github.com/AlexisTrouve/unreal-mcp" +Repository = "https://github.com/AlexisTrouve/unreal-mcp" +Documentation = "https://github.com/AlexisTrouve/unreal-mcp#readme" + +[tool.hatch.build.targets.wheel] +packages = ["src/unreal_mcp"] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # Pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["unreal_mcp"] + +[tool.mypy] +python_version = "3.11" +strict = true +warn_return_any = true +warn_unused_ignores = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/skills/blueprint-workflow/prompt.md b/skills/blueprint-workflow/prompt.md new file mode 100644 index 0000000..afe78a0 --- /dev/null +++ b/skills/blueprint-workflow/prompt.md @@ -0,0 +1,260 @@ +# Blueprint Workflow Skill + +You are executing a Blueprint workflow command for Unreal Engine. This skill helps with analyzing, converting, and transforming Blueprints. + +## Command: {{command}} +## Arguments: {{args}} + +--- + +## Instructions by Command + +### If command is `analyze` + +1. Use `read_blueprint` tool to parse the Blueprint +2. Use `profile_blueprint` tool if editor is connected +3. Analyze for common issues: + - Heavy Event Tick usage (operations per frame) + - Blueprint casts (expensive operations) + - Tick-dependent logic that could be event-driven + - Large Blueprint graphs that should be split + - Missing async loading for assets + - Component lookups that should be cached + +4. Report findings in this format: +``` +## Performance Analysis: + +### Issues Found +🔴 HIGH: + → Suggestion: + +🟡 MEDIUM: + → Suggestion: + +🟢 LOW: + → Suggestion: + +### Metrics +- Estimated Tick Cost: operations/frame +- Complexity Score: +- Optimization Potential: % + +### Recommendations +1. +2. +``` + +--- + +### If command is `bp-to-cpp` + +1. Use `read_blueprint` tool to parse the Blueprint structure +2. Extract: + - Variables (types, defaults, replication) + - Functions (parameters, return types) + - Events (delegates, dispatchers) + - Components + +3. Generate C++ code following Epic standards: + - Proper UCLASS, UPROPERTY, UFUNCTION macros + - Blueprint-friendly specifiers (BlueprintCallable, BlueprintReadWrite) + - Network replication if needed (Replicated, ReplicatedUsing) + - Categories for organization + +4. Output format: +```cpp +// .h +#pragma once + +#include "CoreMinimal.h" +#include ".h" +#include ".generated.h" + +UCLASS(Blueprintable) +class MYPROJECT_API : public +{ + GENERATED_BODY() + +public: + (); + + // Properties + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "...") + ; + + // Functions + UFUNCTION(BlueprintCallable, Category = "...") + (); + +protected: + virtual void BeginPlay() override; +}; +``` + +```cpp +// .cpp +#include ".h" + +::() +{ + // Set defaults +} + +void ::BeginPlay() +{ + Super::BeginPlay(); +} + + ::() +{ + // Implementation +} +``` + +5. Note any Blueprint-specific logic that cannot be directly converted + +--- + +### If command is `cpp-to-bp` + +1. Use `scan_cpp_classes` to find the C++ class +2. Extract exposed properties and functions (UPROPERTY, UFUNCTION with Blueprint specifiers) +3. Use `create_blueprint_from_cpp` tool to create the Blueprint + +4. If editor not connected, generate a Python script: +```python +import unreal + +# Create Blueprint from +factory = unreal.BlueprintFactory() +factory.set_editor_property('parent_class', unreal.find_class('')) + +asset_tools = unreal.AssetToolsHelpers.get_asset_tools() +blueprint = asset_tools.create_asset( + '', + '', + unreal.Blueprint, + factory +) + +if blueprint: + unreal.EditorAssetLibrary.save_asset('/') + unreal.log('Created: /') +``` + +5. Report what was created: +``` +## Blueprint Created: + +### Parent Class + + +### Exposed Properties (tweakable in Blueprint) +- : = +- ... + +### Callable Functions +- () → +- ... + +### Events +- +- ... + +### Location +/ +``` + +--- + +### If command is `transform` + +1. Use `read_blueprint` to get current Blueprint structure +2. Understand the modification request: "{{args}}" +3. Design the changes needed: + - New variables + - New functions + - Modified logic + - New components + +4. Generate the transformation: + - If simple: Generate Python script to modify Blueprint + - If complex: Generate new C++ base class + new Blueprint + +5. Report the transformation: +``` +## Blueprint Transformation: + +### Requested Change +"" + +### Changes Made +1. Added variable: () +2. Added function: () +3. Modified: + +### New Features +- + +### Generated Files +- +- + +### Next Steps +1. +``` + +--- + +### If command is `optimize` + +1. First run `analyze` workflow internally +2. Identify optimization opportunities: + - Event Tick → Events + Timers + - Blueprint casts → Interfaces + - Heavy logic → C++ conversion candidates + - Repeated operations → Caching + +3. For each HIGH impact issue: + - Generate optimized C++ code + - Or generate Python script to refactor Blueprint + +4. Report: +``` +## Optimization Report: + +### Before +- Tick Cost: ops/frame +- Complexity: + +### After (Estimated) +- Tick Cost: ops/frame +- Complexity: +- **Performance Gain: +%** + +### Optimizations Applied +1. + - Before: + - After: + - Gain: +% + +### Generated Files +- - +- - + +### Manual Steps Required +1. +2. +``` + +--- + +## Important Notes + +- Always use MCP tools when available (`read_blueprint`, `create_blueprint_from_cpp`, etc.) +- If editor not connected, generate scripts for manual execution +- Follow Epic Coding Standards for all C++ code +- Preserve Blueprint functionality when converting +- Add comments explaining complex conversions +- Warn about any functionality that cannot be preserved diff --git a/skills/blueprint-workflow/skill.yaml b/skills/blueprint-workflow/skill.yaml new file mode 100644 index 0000000..7ce6d27 --- /dev/null +++ b/skills/blueprint-workflow/skill.yaml @@ -0,0 +1,72 @@ +name: blueprint-workflow +description: | + Blueprint workflow operations for Unreal Engine. + Supports analyzing, converting, and transforming Blueprints. + +commands: + - name: analyze + description: Analyze a Blueprint for performance issues and anti-patterns + usage: /blueprint-workflow analyze + examples: + - /blueprint-workflow analyze BP_Enemy.uasset + - /blueprint-workflow analyze /Game/Characters/BP_Player + + - name: bp-to-cpp + description: Convert a Blueprint to optimized C++ code + usage: /blueprint-workflow bp-to-cpp [output_path] + examples: + - /blueprint-workflow bp-to-cpp BP_Enemy.uasset + - /blueprint-workflow bp-to-cpp BP_Weapon.uasset Source/MyGame/Weapons/ + + - name: cpp-to-bp + description: Generate a Blueprint from a C++ class + usage: /blueprint-workflow cpp-to-bp [blueprint_name] [output_path] + examples: + - /blueprint-workflow cpp-to-bp AWeapon + - /blueprint-workflow cpp-to-bp AWeapon BP_Weapon /Game/Weapons + + - name: transform + description: Transform a Blueprint with AI-powered modifications + usage: /blueprint-workflow transform "" + examples: + - /blueprint-workflow transform BP_Character.uasset "add dash ability" + - /blueprint-workflow transform BP_Enemy.uasset "add patrol behavior" + + - name: optimize + description: Automatically optimize a Blueprint and generate C++ where beneficial + usage: /blueprint-workflow optimize + examples: + - /blueprint-workflow optimize BP_AI.uasset + - /blueprint-workflow optimize /Game/Systems/BP_InventoryManager + +arguments: + blueprint_path: + description: Path to the Blueprint asset (.uasset file or Unreal content path) + required: true + + cpp_class: + description: Name of the C++ class (e.g., AWeapon, UHealthComponent) + required: true + + output_path: + description: Output path for generated files + required: false + + modification_description: + description: Natural language description of the desired modification + required: true + +mcp_tools_used: + - read_blueprint + - create_blueprint_from_cpp + - get_spawnable_classes + - scan_cpp_classes + - profile_blueprint + - execute_python_script + +tags: + - blueprint + - cpp + - optimization + - conversion + - unreal diff --git a/src/unreal_mcp/__init__.py b/src/unreal_mcp/__init__.py new file mode 100644 index 0000000..08a35c1 --- /dev/null +++ b/src/unreal_mcp/__init__.py @@ -0,0 +1,3 @@ +"""Unreal MCP Server - AI-powered Unreal Engine development.""" + +__version__ = "0.1.0" diff --git a/src/unreal_mcp/__pycache__/__init__.cpython-312.pyc b/src/unreal_mcp/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..bec8528 Binary files /dev/null and b/src/unreal_mcp/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/unreal_mcp/core/__init__.py b/src/unreal_mcp/core/__init__.py new file mode 100644 index 0000000..9875cd5 --- /dev/null +++ b/src/unreal_mcp/core/__init__.py @@ -0,0 +1,9 @@ +"""Core modules for Unreal MCP Server.""" + +from unreal_mcp.core.unreal_connection import UnrealConnection +from unreal_mcp.core.uasset_parser import UAssetParser + +__all__ = [ + "UnrealConnection", + "UAssetParser", +] diff --git a/src/unreal_mcp/core/__pycache__/__init__.cpython-312.pyc b/src/unreal_mcp/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..2024bfd Binary files /dev/null and b/src/unreal_mcp/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/unreal_mcp/core/__pycache__/uasset_parser.cpython-312.pyc b/src/unreal_mcp/core/__pycache__/uasset_parser.cpython-312.pyc new file mode 100644 index 0000000..8724eaa Binary files /dev/null and b/src/unreal_mcp/core/__pycache__/uasset_parser.cpython-312.pyc differ diff --git a/src/unreal_mcp/core/__pycache__/unreal_connection.cpython-312.pyc b/src/unreal_mcp/core/__pycache__/unreal_connection.cpython-312.pyc new file mode 100644 index 0000000..1591897 Binary files /dev/null and b/src/unreal_mcp/core/__pycache__/unreal_connection.cpython-312.pyc differ diff --git a/src/unreal_mcp/core/uasset_parser.py b/src/unreal_mcp/core/uasset_parser.py new file mode 100644 index 0000000..b79ae60 --- /dev/null +++ b/src/unreal_mcp/core/uasset_parser.py @@ -0,0 +1,660 @@ +"""Parser for Unreal .uasset files (fallback when editor is closed). + +Reference: https://github.com/atenfyr/UAssetAPI +UAsset format documentation: https://docs.fileformat.com/game/uasset/ +""" + +import re +import struct +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from unreal_mcp.utils.logger import get_logger + +logger = get_logger(__name__) + + +class UAssetParseError(Exception): + """Error parsing a .uasset file.""" + + pass + + +# Magic numbers +UASSET_MAGIC = 0x9E2A83C1 +UEXP_MAGIC = 0x9E2A83C1 + +# Package flags +PKG_FILTER_EDITOR_ONLY = 0x80000000 + + +@dataclass +class FGenerationInfo: + """Generation info for package.""" + + export_count: int = 0 + name_count: int = 0 + + +@dataclass +class FEngineVersion: + """Engine version info.""" + + major: int = 0 + minor: int = 0 + patch: int = 0 + changelist: int = 0 + branch: str = "" + + +@dataclass +class UAssetHeader: + """Parsed .uasset header information.""" + + magic: int = 0 + legacy_version: int = 0 + legacy_ue3_version: int = 0 + file_version_ue4: int = 0 + file_version_ue5: int = 0 + file_version_licensee: int = 0 + custom_versions: list[tuple[str, int]] = field(default_factory=list) + total_header_size: int = 0 + folder_name: str = "" + package_flags: int = 0 + name_count: int = 0 + name_offset: int = 0 + soft_object_paths_count: int = 0 + soft_object_paths_offset: int = 0 + localization_id: str = "" + gatherable_text_data_count: int = 0 + gatherable_text_data_offset: int = 0 + export_count: int = 0 + export_offset: int = 0 + import_count: int = 0 + import_offset: int = 0 + depends_offset: int = 0 + string_asset_references_count: int = 0 + string_asset_references_offset: int = 0 + searchable_names_offset: int = 0 + thumbnail_table_offset: int = 0 + guid: str = "" + generations: list[FGenerationInfo] = field(default_factory=list) + engine_version: FEngineVersion = field(default_factory=FEngineVersion) + compatible_version: FEngineVersion = field(default_factory=FEngineVersion) + compression_flags: int = 0 + package_source: int = 0 + is_unversioned: bool = False + + @property + def is_editor_only(self) -> bool: + """Check if this is an editor-only package.""" + return bool(self.package_flags & PKG_FILTER_EDITOR_ONLY) + + +@dataclass +class FName: + """Unreal name entry.""" + + name: str + index: int = 0 + number: int = 0 + + +@dataclass +class FObjectImport: + """Import table entry.""" + + class_package: str = "" + class_name: str = "" + outer_index: int = 0 + object_name: str = "" + + +@dataclass +class FObjectExport: + """Export table entry.""" + + class_index: int = 0 + super_index: int = 0 + template_index: int = 0 + outer_index: int = 0 + object_name: str = "" + object_flags: int = 0 + serial_size: int = 0 + serial_offset: int = 0 + forced_export: bool = False + not_for_client: bool = False + not_for_server: bool = False + package_guid: str = "" + package_flags: int = 0 + not_always_loaded_for_editor_game: bool = False + is_asset: bool = False + + +class UAssetParser: + """Parser for Unreal .uasset files. + + Provides offline parsing of .uasset files to extract: + - Header information (version, engine, etc.) + - Name table + - Import/Export tables + - Asset metadata + """ + + def __init__(self, file_path: Path | str): + """Initialize the parser with a file path. + + Args: + file_path: Path to the .uasset file + """ + self.file_path = Path(file_path) + self._data: bytes = b"" + self._offset: int = 0 + self.header: UAssetHeader = UAssetHeader() + self.names: list[FName] = [] + self.imports: list[FObjectImport] = [] + self.exports: list[FObjectExport] = [] + + def parse(self) -> dict[str, Any]: + """Parse the .uasset file. + + Returns: + Dictionary containing parsed asset information + + Raises: + UAssetParseError: If the file cannot be parsed + """ + if not self.file_path.exists(): + raise UAssetParseError(f"File not found: {self.file_path}") + + try: + with open(self.file_path, "rb") as f: + self._data = f.read() + + self._offset = 0 + + # Parse header + self._parse_header() + + # Parse name table + self._parse_names() + + # Parse import table + self._parse_imports() + + # Parse export table + self._parse_exports() + + return self._build_result() + + except UAssetParseError: + raise + except Exception as e: + logger.error(f"Failed to parse {self.file_path}: {e}") + raise UAssetParseError(f"Parse error: {e}") from e + + def _read_int32(self) -> int: + """Read a signed 32-bit integer.""" + value = struct.unpack_from(" int: + """Read an unsigned 32-bit integer.""" + value = struct.unpack_from(" int: + """Read a signed 64-bit integer.""" + value = struct.unpack_from(" int: + """Read an unsigned 64-bit integer.""" + value = struct.unpack_from(" int: + """Read an unsigned 16-bit integer.""" + value = struct.unpack_from(" str: + """Read a GUID (16 bytes).""" + guid_bytes = self._data[self._offset : self._offset + 16] + self._offset += 16 + return guid_bytes.hex().upper() + + def _read_fstring(self) -> str: + """Read an FString (length-prefixed string).""" + length = self._read_int32() + + if length == 0: + return "" + + # Negative length means Unicode + if length < 0: + length = -length + data = self._data[self._offset : self._offset + length * 2] + self._offset += length * 2 + return data.decode("utf-16-le").rstrip("\x00") + else: + data = self._data[self._offset : self._offset + length] + self._offset += length + return data.decode("utf-8", errors="replace").rstrip("\x00") + + def _parse_header(self) -> None: + """Parse the asset header.""" + if len(self._data) < 4: + raise UAssetParseError("File too small to be a valid .uasset") + + # Magic number + self.header.magic = self._read_uint32() + if self.header.magic != UASSET_MAGIC: + raise UAssetParseError(f"Invalid magic number: {hex(self.header.magic)}") + + # Legacy version + self.header.legacy_version = self._read_int32() + + if self.header.legacy_version < -7: + # UE5 format + self.header.legacy_ue3_version = self._read_int32() + self.header.file_version_ue4 = self._read_int32() + self.header.file_version_ue5 = self._read_int32() + self.header.file_version_licensee = self._read_int32() + elif self.header.legacy_version != -4: + # UE4 format + self.header.legacy_ue3_version = self._read_int32() + self.header.file_version_ue4 = self._read_int32() + self.header.file_version_licensee = self._read_int32() + + # Custom versions (skip for now - complex parsing) + custom_version_count = self._read_int32() + for _ in range(custom_version_count): + self._read_guid() # Key + self._read_int32() # Version + + # Total header size + self.header.total_header_size = self._read_int32() + + # Folder name + self.header.folder_name = self._read_fstring() + + # Package flags + self.header.package_flags = self._read_uint32() + + # Name table + self.header.name_count = self._read_int32() + self.header.name_offset = self._read_int32() + + # Soft object paths (UE5) + if self.header.file_version_ue5 >= 0: + self.header.soft_object_paths_count = self._read_int32() + self.header.soft_object_paths_offset = self._read_int32() + + # Localization ID (optional) + if self.header.file_version_ue4 >= 516: + self.header.localization_id = self._read_fstring() + + # Gatherable text data + if self.header.file_version_ue4 >= 459: + self.header.gatherable_text_data_count = self._read_int32() + self.header.gatherable_text_data_offset = self._read_int32() + + # Export table + self.header.export_count = self._read_int32() + self.header.export_offset = self._read_int32() + + # Import table + self.header.import_count = self._read_int32() + self.header.import_offset = self._read_int32() + + # Depends offset + self.header.depends_offset = self._read_int32() + + # String asset references + if self.header.file_version_ue4 >= 384: + self.header.string_asset_references_count = self._read_int32() + self.header.string_asset_references_offset = self._read_int32() + + # Searchable names offset + if self.header.file_version_ue4 >= 510: + self.header.searchable_names_offset = self._read_int32() + + # Thumbnail table offset + self.header.thumbnail_table_offset = self._read_int32() + + # GUID + self.header.guid = self._read_guid() + + # Generations + generation_count = self._read_int32() + for _ in range(generation_count): + gen = FGenerationInfo( + export_count=self._read_int32(), + name_count=self._read_int32(), + ) + self.header.generations.append(gen) + + # Engine version + if self.header.file_version_ue4 >= 336: + self.header.engine_version = FEngineVersion( + major=self._read_uint16(), + minor=self._read_uint16(), + patch=self._read_uint16(), + changelist=self._read_uint32(), + branch=self._read_fstring(), + ) + + # Compatible version + if self.header.file_version_ue4 >= 444: + self.header.compatible_version = FEngineVersion( + major=self._read_uint16(), + minor=self._read_uint16(), + patch=self._read_uint16(), + changelist=self._read_uint32(), + branch=self._read_fstring(), + ) + + # Compression flags + self.header.compression_flags = self._read_uint32() + + # Package source + self.header.package_source = self._read_uint32() + + def _parse_names(self) -> None: + """Parse the name table.""" + if self.header.name_offset == 0 or self.header.name_count == 0: + return + + self._offset = self.header.name_offset + + for i in range(self.header.name_count): + try: + name_str = self._read_fstring() + + # Read hash (UE4.12+) + if self.header.file_version_ue4 >= 64: + self._read_uint32() # Skip hash + + self.names.append(FName(name=name_str, index=i)) + except Exception as e: + logger.debug(f"Error parsing name {i}: {e}") + break + + def _parse_imports(self) -> None: + """Parse the import table.""" + if self.header.import_offset == 0 or self.header.import_count == 0: + return + + self._offset = self.header.import_offset + + for _ in range(self.header.import_count): + try: + imp = FObjectImport() + class_package_idx = self._read_int64() if self.header.file_version_ue5 >= 0 else self._read_int32() + class_idx = self._read_int64() if self.header.file_version_ue5 >= 0 else self._read_int32() + imp.outer_index = self._read_int32() + object_name_idx = self._read_int64() if self.header.file_version_ue5 >= 0 else self._read_int32() + + # Resolve names + if 0 <= class_package_idx < len(self.names): + imp.class_package = self.names[class_package_idx].name + if 0 <= class_idx < len(self.names): + imp.class_name = self.names[class_idx].name + if 0 <= object_name_idx < len(self.names): + imp.object_name = self.names[object_name_idx].name + + # Skip optional package name (UE5) + if self.header.file_version_ue5 >= 0: + self._read_int32() + + self.imports.append(imp) + except Exception as e: + logger.debug(f"Error parsing import: {e}") + break + + def _parse_exports(self) -> None: + """Parse the export table.""" + if self.header.export_offset == 0 or self.header.export_count == 0: + return + + self._offset = self.header.export_offset + + for _ in range(self.header.export_count): + try: + exp = FObjectExport() + exp.class_index = self._read_int32() + exp.super_index = self._read_int32() + + if self.header.file_version_ue4 >= 508: + exp.template_index = self._read_int32() + + exp.outer_index = self._read_int32() + object_name_idx = self._read_int64() if self.header.file_version_ue5 >= 0 else self._read_int32() + + if 0 <= object_name_idx < len(self.names): + exp.object_name = self.names[object_name_idx].name + + exp.object_flags = self._read_uint32() + exp.serial_size = self._read_int64() if self.header.file_version_ue5 >= 0 else self._read_int32() + exp.serial_offset = self._read_int64() if self.header.file_version_ue5 >= 0 else self._read_int32() + + exp.forced_export = bool(self._read_int32()) + exp.not_for_client = bool(self._read_int32()) + exp.not_for_server = bool(self._read_int32()) + + # Skip remaining fields based on version + if self.header.file_version_ue4 < 220: + self._read_guid() + + exp.package_flags = self._read_uint32() + + if self.header.file_version_ue4 >= 365: + exp.not_always_loaded_for_editor_game = bool(self._read_int32()) + + if self.header.file_version_ue4 >= 485: + exp.is_asset = bool(self._read_int32()) + + # Skip serialization and dependencies + if self.header.file_version_ue5 >= 0: + self._read_int32() # first_export_dependency + self._read_int32() # serialization_before_count + self._read_int32() # create_before_count + self._read_int32() # serialization_after_count + self._read_int32() # create_after_count + + self.exports.append(exp) + except Exception as e: + logger.debug(f"Error parsing export: {e}") + break + + def _build_result(self) -> dict[str, Any]: + """Build the result dictionary.""" + # Determine asset type and parent class + asset_type = "Unknown" + parent_class = None + class_name = self.file_path.stem + + # Check exports for class info + for exp in self.exports: + if exp.class_index < 0: + # Negative index means import + import_idx = -exp.class_index - 1 + if import_idx < len(self.imports): + imp = self.imports[import_idx] + if "Blueprint" in imp.class_name: + asset_type = "Blueprint" + elif imp.class_name: + asset_type = imp.class_name + + if exp.super_index < 0: + import_idx = -exp.super_index - 1 + if import_idx < len(self.imports): + parent_class = self.imports[import_idx].object_name + + # Extract all name strings + name_strings = [n.name for n in self.names if n.name] + + # Find Blueprint-related names + bp_variables = [n for n in name_strings if not n.startswith("/") and "_" in n and n[0].isupper()] + bp_functions = [n for n in name_strings if n.startswith("Execute") or n.endswith("_Implementation")] + + return { + "success": True, + "file_path": str(self.file_path), + "class_name": class_name, + "asset_type": asset_type, + "parent_class": parent_class, + "header": { + "magic": hex(self.header.magic), + "ue4_version": self.header.file_version_ue4, + "ue5_version": self.header.file_version_ue5, + "engine_version": f"{self.header.engine_version.major}.{self.header.engine_version.minor}.{self.header.engine_version.patch}", + "guid": self.header.guid, + "is_editor_only": self.header.is_editor_only, + }, + "names": name_strings[:100], # Limit to first 100 + "name_count": len(self.names), + "imports": [ + {"class": imp.class_name, "package": imp.class_package, "object": imp.object_name} + for imp in self.imports[:50] + ], + "import_count": len(self.imports), + "exports": [ + {"name": exp.object_name, "is_asset": exp.is_asset} + for exp in self.exports[:50] + ], + "export_count": len(self.exports), + "variables": bp_variables[:20], + "functions": bp_functions[:20], + } + + @staticmethod + def get_asset_info_from_path(asset_path: Path) -> dict[str, Any]: + """Get basic asset info from file path without full parsing. + + Args: + asset_path: Path to the .uasset file + + Returns: + Dictionary with basic asset info + """ + name = asset_path.stem + + # Determine asset type from naming convention + asset_type = "Unknown" + prefixes = { + "BP_": "Blueprint", + "M_": "Material", + "MI_": "MaterialInstance", + "T_": "Texture", + "SM_": "StaticMesh", + "SK_": "SkeletalMesh", + "SKM_": "SkeletalMesh", + "A_": "Animation", + "ABP_": "AnimBlueprint", + "AM_": "AimOffset", + "BS_": "BlendSpace", + "WBP_": "WidgetBlueprint", + "DA_": "DataAsset", + "DT_": "DataTable", + "E_": "Enum", + "S_": "Struct", + "PS_": "ParticleSystem", + "NS_": "NiagaraSystem", + "NE_": "NiagaraEmitter", + "SB_": "SoundBase", + "SC_": "SoundCue", + "SW_": "SoundWave", + "P_": "Particle", + "L_": "Level", + "GI_": "GameplayAbility", + "GE_": "GameplayEffect", + "GA_": "GameplayAbility", + } + + for prefix, atype in prefixes.items(): + if name.startswith(prefix): + asset_type = atype + break + + return { + "name": name, + "type": asset_type, + "path": str(asset_path), + "size": asset_path.stat().st_size if asset_path.exists() else 0, + } + + +def scan_content_folder(content_path: Path) -> list[dict[str, Any]]: + """Scan a Content folder for all .uasset files. + + Args: + content_path: Path to the Content folder + + Returns: + List of asset info dictionaries + """ + assets = [] + + if not content_path.exists(): + logger.warning(f"Content path does not exist: {content_path}") + return assets + + for uasset_file in content_path.rglob("*.uasset"): + asset_info = UAssetParser.get_asset_info_from_path(uasset_file) + # Convert to relative path within Content + try: + rel_path = uasset_file.relative_to(content_path) + asset_info["content_path"] = f"/Game/{rel_path.with_suffix('').as_posix()}" + except ValueError: + asset_info["content_path"] = None + assets.append(asset_info) + + logger.info(f"Found {len(assets)} assets in {content_path}") + return assets + + +def parse_blueprint_details(file_path: Path) -> dict[str, Any]: + """Parse a Blueprint file and extract detailed information. + + Args: + file_path: Path to the Blueprint .uasset file + + Returns: + Dictionary with Blueprint details + """ + parser = UAssetParser(file_path) + result = parser.parse() + + # Enhance with Blueprint-specific analysis + names = result.get("names", []) + + # Find variables (properties) + variables = [] + type_patterns = ["Float", "Int", "Bool", "String", "Vector", "Rotator", "Transform", "Actor", "Object"] + for name in names: + for type_name in type_patterns: + if type_name in name and not name.startswith("/"): + variables.append(name) + break + + # Find events + events = [n for n in names if n.startswith("On") or n.endswith("Event")] + + # Find function implementations + functions = [n for n in names if "_Implementation" in n or n.startswith("Execute")] + + result["blueprint_details"] = { + "variables": list(set(variables))[:30], + "events": list(set(events))[:20], + "functions": list(set(functions))[:20], + } + + return result diff --git a/src/unreal_mcp/core/unreal_connection.py b/src/unreal_mcp/core/unreal_connection.py new file mode 100644 index 0000000..a159e59 --- /dev/null +++ b/src/unreal_mcp/core/unreal_connection.py @@ -0,0 +1,631 @@ +"""Connection to Unreal Engine Editor via Python Remote Execution. + +Protocol based on Unreal Engine's built-in Python Remote Execution. +Reference: PythonScriptRemoteExecution.cpp in Unreal Engine source. + +Architecture: +- UDP Multicast (239.0.0.1:6766) for node discovery (ping/pong) +- TCP (default 6776) for command execution +""" + +import json +import socket +import struct +import threading +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any + +from unreal_mcp.utils.logger import get_logger + +logger = get_logger(__name__) + +# Protocol constants (from Unreal Engine) +_PROTOCOL_VERSION = 1 +_PROTOCOL_MAGIC = "ue_py" + +# Message types +_TYPE_PING = "ping" +_TYPE_PONG = "pong" +_TYPE_OPEN_CONNECTION = "open_connection" +_TYPE_CLOSE_CONNECTION = "close_connection" +_TYPE_COMMAND = "command" +_TYPE_COMMAND_RESULT = "command_result" + +# Default endpoints +DEFAULT_MULTICAST_GROUP = "239.0.0.1" +DEFAULT_MULTICAST_PORT = 6766 +DEFAULT_COMMAND_PORT = 6776 + +# Timeouts +NODE_PING_INTERVAL = 1.0 +NODE_TIMEOUT = 5.0 +COMMAND_TIMEOUT = 30.0 +CONNECTION_RETRY_COUNT = 6 +CONNECTION_RETRY_TIMEOUT = 5.0 + + +class ExecutionMode(Enum): + """Python execution modes supported by Unreal.""" + + EXECUTE_FILE = "ExecuteFile" # Execute script file or literal with args + EXECUTE_STATEMENT = "ExecuteStatement" # Execute statement, print result + EVALUATE_STATEMENT = "EvaluateStatement" # Evaluate expression, return result + + +@dataclass +class RemoteExecutionConfig: + """Configuration for remote execution.""" + + multicast_group: str = DEFAULT_MULTICAST_GROUP + multicast_port: int = DEFAULT_MULTICAST_PORT + multicast_bind_address: str = "0.0.0.0" + multicast_ttl: int = 0 + command_port: int = DEFAULT_COMMAND_PORT + + +@dataclass +class UnrealNode: + """Represents a discovered Unreal Engine instance.""" + + node_id: str + project_name: str + project_root: str + engine_version: str + engine_root: str + host: str + port: int + last_pong: float = field(default_factory=time.time) + + def is_alive(self) -> bool: + """Check if node is still responding.""" + return (time.time() - self.last_pong) < NODE_TIMEOUT + + +class UnrealConnectionError(Exception): + """Error connecting to or communicating with Unreal Editor.""" + + pass + + +class RemoteExecutionMessage: + """Message wrapper for the remote execution protocol.""" + + def __init__( + self, + msg_type: str, + source: str, + dest: str | None = None, + data: dict[str, Any] | None = None, + ): + self.version = _PROTOCOL_VERSION + self.magic = _PROTOCOL_MAGIC + self.type = msg_type + self.source = source + self.dest = dest + self.data = data or {} + + def to_bytes(self) -> bytes: + """Serialize message to bytes for sending.""" + msg = { + "version": self.version, + "magic": self.magic, + "type": self.type, + "source": self.source, + } + if self.dest: + msg["dest"] = self.dest + if self.data: + msg["data"] = self.data + + json_str = json.dumps(msg) + encoded = json_str.encode("utf-8") + + # Message format: 4-byte length prefix + JSON data + return struct.pack("!I", len(encoded)) + encoded + + @classmethod + def from_bytes(cls, data: bytes) -> "RemoteExecutionMessage": + """Deserialize message from bytes.""" + if len(data) < 4: + raise ValueError("Message too short") + + length = struct.unpack("!I", data[:4])[0] + json_data = data[4 : 4 + length].decode("utf-8") + msg = json.loads(json_data) + + return cls( + msg_type=msg.get("type", ""), + source=msg.get("source", ""), + dest=msg.get("dest"), + data=msg.get("data"), + ) + + @classmethod + def from_json(cls, json_str: str) -> "RemoteExecutionMessage": + """Create message from JSON string.""" + msg = json.loads(json_str) + return cls( + msg_type=msg.get("type", ""), + source=msg.get("source", ""), + dest=msg.get("dest"), + data=msg.get("data"), + ) + + +class UnrealConnection: + """Manages connection to Unreal Engine Editor. + + Uses Unreal's built-in Python Remote Execution protocol: + - UDP multicast for node discovery + - TCP for command execution + + Supports two modes: + 1. Remote Execution: Connect to running editor via Remote Execution + 2. Offline: Fallback to file-based operations when editor is not running + """ + + def __init__( + self, + host: str = "localhost", + port: int = DEFAULT_COMMAND_PORT, + project_path: Path | None = None, + config: RemoteExecutionConfig | None = None, + ): + """Initialize the Unreal connection. + + Args: + host: Host for command connection + port: Port for command connection (default 6776) + project_path: Path to the Unreal project (for offline mode) + config: Remote execution configuration + """ + self.host = host + self.port = port + self.project_path = project_path + self.config = config or RemoteExecutionConfig() + + self._node_id = str(uuid.uuid4()) + self._command_socket: socket.socket | None = None + self._broadcast_socket: socket.socket | None = None + self._connected = False + self._connected_node: UnrealNode | None = None + + # Node discovery + self._nodes: dict[str, UnrealNode] = {} + self._nodes_lock = threading.Lock() + self._discovery_thread: threading.Thread | None = None + self._discovery_running = False + + @property + def is_connected(self) -> bool: + """Check if connected to Unreal Editor.""" + return self._connected and self._connected_node is not None + + def start_discovery(self) -> None: + """Start node discovery via UDP multicast.""" + if self._discovery_running: + return + + self._discovery_running = True + self._setup_broadcast_socket() + + self._discovery_thread = threading.Thread( + target=self._discovery_loop, + daemon=True, + ) + self._discovery_thread.start() + logger.info("Started Unreal node discovery") + + def stop_discovery(self) -> None: + """Stop node discovery.""" + self._discovery_running = False + if self._discovery_thread: + self._discovery_thread.join(timeout=2.0) + self._discovery_thread = None + + if self._broadcast_socket: + try: + self._broadcast_socket.close() + except socket.error: + pass + self._broadcast_socket = None + + def _setup_broadcast_socket(self) -> None: + """Setup UDP multicast socket for discovery.""" + self._broadcast_socket = socket.socket( + socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP + ) + self._broadcast_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + # Enable multicast + self._broadcast_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1 + ) + self._broadcast_socket.setsockopt( + socket.IPPROTO_IP, + socket.IP_MULTICAST_TTL, + self.config.multicast_ttl, + ) + + # Bind to receive responses + self._broadcast_socket.bind( + (self.config.multicast_bind_address, self.config.multicast_port) + ) + + # Join multicast group + mreq = struct.pack( + "4sl", + socket.inet_aton(self.config.multicast_group), + socket.INADDR_ANY, + ) + self._broadcast_socket.setsockopt( + socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq + ) + + self._broadcast_socket.settimeout(0.5) + + def _discovery_loop(self) -> None: + """Background thread for node discovery.""" + last_ping = 0.0 + + while self._discovery_running: + now = time.time() + + # Send ping periodically + if now - last_ping >= NODE_PING_INTERVAL: + self._send_ping() + last_ping = now + + # Receive responses + self._receive_broadcast() + + # Clean up timed-out nodes + self._cleanup_nodes() + + def _send_ping(self) -> None: + """Send discovery ping.""" + if not self._broadcast_socket: + return + + msg = RemoteExecutionMessage( + msg_type=_TYPE_PING, + source=self._node_id, + ) + + try: + # Send without length prefix for UDP + json_str = json.dumps({ + "version": msg.version, + "magic": msg.magic, + "type": msg.type, + "source": msg.source, + }) + self._broadcast_socket.sendto( + json_str.encode("utf-8"), + (self.config.multicast_group, self.config.multicast_port), + ) + except socket.error as e: + logger.debug(f"Failed to send ping: {e}") + + def _receive_broadcast(self) -> None: + """Receive and process broadcast messages.""" + if not self._broadcast_socket: + return + + try: + data, addr = self._broadcast_socket.recvfrom(4096) + self._handle_broadcast_message(data, addr) + except socket.timeout: + pass + except socket.error as e: + logger.debug(f"Broadcast receive error: {e}") + + def _handle_broadcast_message(self, data: bytes, addr: tuple[str, int]) -> None: + """Handle received broadcast message.""" + try: + msg = json.loads(data.decode("utf-8")) + + if msg.get("magic") != _PROTOCOL_MAGIC: + return + if msg.get("type") != _TYPE_PONG: + return + + # Extract node info from pong + node_data = msg.get("data", {}) + node = UnrealNode( + node_id=msg.get("source", ""), + project_name=node_data.get("project_name", "Unknown"), + project_root=node_data.get("project_root", ""), + engine_version=node_data.get("engine_version", ""), + engine_root=node_data.get("engine_root", ""), + host=addr[0], + port=node_data.get("command_port", DEFAULT_COMMAND_PORT), + last_pong=time.time(), + ) + + with self._nodes_lock: + self._nodes[node.node_id] = node + logger.debug(f"Discovered Unreal node: {node.project_name} at {addr}") + + except (json.JSONDecodeError, KeyError) as e: + logger.debug(f"Failed to parse broadcast message: {e}") + + def _cleanup_nodes(self) -> None: + """Remove timed-out nodes.""" + with self._nodes_lock: + to_remove = [ + node_id for node_id, node in self._nodes.items() if not node.is_alive() + ] + for node_id in to_remove: + del self._nodes[node_id] + logger.debug(f"Node timed out: {node_id}") + + def get_nodes(self) -> list[UnrealNode]: + """Get list of discovered Unreal nodes.""" + with self._nodes_lock: + return [node for node in self._nodes.values() if node.is_alive()] + + def connect(self, node: UnrealNode | None = None) -> bool: + """Connect to an Unreal Editor instance. + + Args: + node: Specific node to connect to. If None, connects to first available. + + Returns: + True if connected successfully, False otherwise + """ + # Start discovery if not running + if not self._discovery_running: + self.start_discovery() + # Wait a bit for discovery + time.sleep(NODE_PING_INTERVAL * 2) + + # Select node + if node is None: + nodes = self.get_nodes() + if not nodes: + logger.warning("No Unreal nodes discovered") + return False + node = nodes[0] + + # Open TCP connection + try: + # First, send open_connection via UDP + self._send_open_connection(node) + + # Then connect via TCP + self._command_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._command_socket.settimeout(CONNECTION_RETRY_TIMEOUT) + + for attempt in range(CONNECTION_RETRY_COUNT): + try: + self._command_socket.connect((node.host, node.port)) + self._connected = True + self._connected_node = node + logger.info( + f"Connected to Unreal: {node.project_name} at {node.host}:{node.port}" + ) + return True + except socket.error as e: + logger.debug(f"Connection attempt {attempt + 1} failed: {e}") + time.sleep(0.5) + + logger.warning(f"Failed to connect to {node.host}:{node.port}") + return False + + except socket.error as e: + logger.error(f"Connection error: {e}") + self._connected = False + return False + + def _send_open_connection(self, node: UnrealNode) -> None: + """Send open_connection message via UDP.""" + if not self._broadcast_socket: + return + + msg_data = { + "version": _PROTOCOL_VERSION, + "magic": _PROTOCOL_MAGIC, + "type": _TYPE_OPEN_CONNECTION, + "source": self._node_id, + "dest": node.node_id, + } + + try: + self._broadcast_socket.sendto( + json.dumps(msg_data).encode("utf-8"), + (self.config.multicast_group, self.config.multicast_port), + ) + except socket.error as e: + logger.debug(f"Failed to send open_connection: {e}") + + def disconnect(self) -> None: + """Disconnect from Unreal Editor.""" + if self._connected_node and self._broadcast_socket: + # Send close_connection + msg_data = { + "version": _PROTOCOL_VERSION, + "magic": _PROTOCOL_MAGIC, + "type": _TYPE_CLOSE_CONNECTION, + "source": self._node_id, + "dest": self._connected_node.node_id, + } + try: + self._broadcast_socket.sendto( + json.dumps(msg_data).encode("utf-8"), + (self.config.multicast_group, self.config.multicast_port), + ) + except socket.error: + pass + + if self._command_socket: + try: + self._command_socket.close() + except socket.error: + pass + self._command_socket = None + + self._connected = False + self._connected_node = None + logger.info("Disconnected from Unreal Editor") + + async def execute( + self, + script: str, + mode: ExecutionMode = ExecutionMode.EXECUTE_STATEMENT, + unattended: bool = True, + ) -> dict[str, Any]: + """Execute a Python script in Unreal Editor. + + Args: + script: Python script to execute + mode: Execution mode + unattended: Run without user interaction + + Returns: + Result dictionary with success status and data/error + """ + # Try to connect if not connected + if not self._connected: + if not self.connect(): + return { + "success": False, + "error": "Not connected to Unreal Editor", + "code": "NOT_CONNECTED", + } + + if self._command_socket is None: + return { + "success": False, + "error": "Command socket is None", + "code": "SOCKET_ERROR", + } + + try: + # Build command message + command_data = { + "command": script, + "unattended": unattended, + "exec_mode": mode.value, + } + + msg = RemoteExecutionMessage( + msg_type=_TYPE_COMMAND, + source=self._node_id, + dest=self._connected_node.node_id if self._connected_node else None, + data=command_data, + ) + + # Send command + self._command_socket.sendall(msg.to_bytes()) + + # Receive response + response = self._receive_command_response() + + if response: + return { + "success": response.data.get("success", False), + "data": response.data.get("result"), + "output": response.data.get("output", ""), + } + else: + return { + "success": False, + "error": "No response received", + "code": "NO_RESPONSE", + } + + except socket.error as e: + self._connected = False + logger.error(f"Socket error during execution: {e}") + return { + "success": False, + "error": str(e), + "code": "SOCKET_ERROR", + } + + def _receive_command_response(self) -> RemoteExecutionMessage | None: + """Receive command response from TCP socket.""" + if self._command_socket is None: + return None + + try: + self._command_socket.settimeout(COMMAND_TIMEOUT) + + # Read length prefix + length_data = self._recv_exact(4) + if not length_data: + return None + + length = struct.unpack("!I", length_data)[0] + + # Read message body + msg_data = self._recv_exact(length) + if not msg_data: + return None + + return RemoteExecutionMessage.from_json(msg_data.decode("utf-8")) + + except socket.timeout: + logger.warning("Command response timeout") + return None + except (json.JSONDecodeError, struct.error) as e: + logger.error(f"Failed to parse response: {e}") + return None + + def _recv_exact(self, size: int) -> bytes | None: + """Receive exactly size bytes from socket.""" + if self._command_socket is None: + return None + + data = b"" + while len(data) < size: + chunk = self._command_socket.recv(size - len(data)) + if not chunk: + return None + data += chunk + return data + + def get_project_content_path(self) -> Path | None: + """Get the Content folder path for the project.""" + # Try from connected node first + if self._connected_node and self._connected_node.project_root: + content_path = Path(self._connected_node.project_root) / "Content" + if content_path.exists(): + return content_path + + # Fall back to configured project path + if self.project_path: + content_path = self.project_path / "Content" + if content_path.exists(): + return content_path + + return None + + def get_project_source_path(self) -> Path | None: + """Get the Source folder path for the project.""" + if self._connected_node and self._connected_node.project_root: + source_path = Path(self._connected_node.project_root) / "Source" + if source_path.exists(): + return source_path + + if self.project_path: + source_path = self.project_path / "Source" + if source_path.exists(): + return source_path + + return None + + def __enter__(self) -> "UnrealConnection": + """Context manager entry.""" + self.start_discovery() + return self + + def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + """Context manager exit.""" + self.disconnect() + self.stop_discovery() diff --git a/src/unreal_mcp/server.py b/src/unreal_mcp/server.py new file mode 100644 index 0000000..94c6643 --- /dev/null +++ b/src/unreal_mcp/server.py @@ -0,0 +1,395 @@ +"""MCP Server for Unreal Engine.""" + +import asyncio +import logging +from typing import Any + +from mcp.server import Server +from mcp.server.stdio import stdio_server +from mcp.types import Tool, TextContent + +from unreal_mcp.core.unreal_connection import UnrealConnection +from unreal_mcp.utils.logger import get_logger +from unreal_mcp.utils.config import Settings + +# Tools imports +from unreal_mcp.tools import project, scene, debug, blueprint + +logger = get_logger(__name__) + +# Create MCP server instance +server = Server("unreal-mcp") + +# Global connection instance +_connection: UnrealConnection | None = None + + +def get_connection() -> UnrealConnection: + """Get or create the Unreal connection.""" + global _connection + if _connection is None: + from unreal_mcp.core.unreal_connection import RemoteExecutionConfig + + settings = Settings() + config = RemoteExecutionConfig( + multicast_group=settings.ue_multicast_group, + multicast_port=settings.ue_multicast_port, + multicast_bind_address=settings.ue_multicast_bind, + multicast_ttl=settings.ue_multicast_ttl, + command_port=settings.ue_command_port, + ) + _connection = UnrealConnection( + host=settings.ue_command_host, + port=settings.ue_command_port, + project_path=settings.ue_project_path, + config=config, + ) + return _connection + + +@server.list_tools() +async def list_tools() -> list[Tool]: + """List all available MCP tools.""" + return [ + # Project Intelligence + Tool( + name="get_spawnable_classes", + description="List all spawnable classes in the project (Blueprints and native actors)", + inputSchema={ + "type": "object", + "properties": { + "filter": { + "type": "string", + "enum": ["all", "blueprint", "native"], + "description": "Filter by class type", + "default": "all", + } + }, + }, + ), + Tool( + name="get_project_assets", + description="Get inventory of all project assets by type", + inputSchema={ + "type": "object", + "properties": { + "asset_type": { + "type": "string", + "description": "Filter by asset type (e.g., 'Blueprint', 'Material')", + } + }, + }, + ), + Tool( + name="scan_cpp_classes", + description="Scan and parse C++ classes in the project Source folder", + inputSchema={ + "type": "object", + "properties": { + "filter_blueprintable": { + "type": "boolean", + "description": "Only return classes marked as Blueprintable", + "default": False, + } + }, + }, + ), + # Scene Manipulation + Tool( + name="spawn_actor", + description="Spawn an actor in the current level", + inputSchema={ + "type": "object", + "properties": { + "class_name": { + "type": "string", + "description": "Name of the class to spawn (e.g., 'BP_Enemy')", + }, + "location": { + "type": "array", + "items": {"type": "number"}, + "description": "Spawn location [X, Y, Z]", + }, + "rotation": { + "type": "array", + "items": {"type": "number"}, + "description": "Spawn rotation [Pitch, Yaw, Roll]", + }, + "properties": { + "type": "object", + "description": "Initial property values to set", + }, + }, + "required": ["class_name", "location"], + }, + ), + Tool( + name="get_scene_hierarchy", + description="Get the hierarchy of actors in the current level", + inputSchema={ + "type": "object", + "properties": { + "include_components": { + "type": "boolean", + "description": "Include component details", + "default": False, + } + }, + }, + ), + Tool( + name="modify_actor_transform", + description="Modify the transform (location/rotation/scale) of an actor", + inputSchema={ + "type": "object", + "properties": { + "actor_id": { + "type": "string", + "description": "ID or name of the actor to modify", + }, + "location": { + "type": "array", + "items": {"type": "number"}, + "description": "New location [X, Y, Z]", + }, + "rotation": { + "type": "array", + "items": {"type": "number"}, + "description": "New rotation [Pitch, Yaw, Roll]", + }, + "scale": { + "type": "array", + "items": {"type": "number"}, + "description": "New scale [X, Y, Z]", + }, + }, + "required": ["actor_id"], + }, + ), + # Debug & Profiling + Tool( + name="get_console_logs", + description="Get Unreal Engine console logs", + inputSchema={ + "type": "object", + "properties": { + "filter": { + "type": "string", + "enum": ["All", "Error", "Warning"], + "description": "Filter logs by level", + "default": "All", + }, + "limit": { + "type": "integer", + "description": "Maximum number of logs to return", + "default": 100, + }, + }, + }, + ), + Tool( + name="analyze_crash_dump", + description="Analyze a crash dump and suggest fixes", + inputSchema={ + "type": "object", + "properties": { + "crash_log": { + "type": "string", + "description": "The crash log content", + }, + "callstack": { + "type": "string", + "description": "The callstack (optional)", + }, + }, + "required": ["crash_log"], + }, + ), + Tool( + name="profile_blueprint", + description="Profile a Blueprint's performance", + inputSchema={ + "type": "object", + "properties": { + "blueprint_path": { + "type": "string", + "description": "Path to the Blueprint asset", + }, + }, + "required": ["blueprint_path"], + }, + ), + # Blueprint Operations + Tool( + name="read_blueprint", + description="Read and parse a Blueprint .uasset file", + inputSchema={ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the .uasset file", + }, + }, + "required": ["file_path"], + }, + ), + Tool( + name="create_blueprint_from_cpp", + description="Create a Blueprint from a C++ class", + inputSchema={ + "type": "object", + "properties": { + "cpp_class": { + "type": "string", + "description": "Name of the C++ class (e.g., 'AWeapon')", + }, + "blueprint_name": { + "type": "string", + "description": "Name for the new Blueprint", + }, + "output_path": { + "type": "string", + "description": "Output path in Content (e.g., '/Game/Weapons')", + }, + "exposed_properties": { + "type": "array", + "items": {"type": "string"}, + "description": "Properties to expose in Blueprint", + }, + }, + "required": ["cpp_class", "blueprint_name", "output_path"], + }, + ), + Tool( + name="execute_python_script", + description="Execute a Python script in Unreal Editor", + inputSchema={ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "Python script content to execute", + }, + "script_path": { + "type": "string", + "description": "Path to a Python script file", + }, + }, + }, + ), + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]: + """Handle tool calls.""" + conn = get_connection() + + try: + # Project Intelligence + if name == "get_spawnable_classes": + result = await project.get_spawnable_classes( + conn, + filter_type=arguments.get("filter", "all"), + ) + elif name == "get_project_assets": + result = await project.get_project_assets( + conn, + asset_type=arguments.get("asset_type"), + ) + elif name == "scan_cpp_classes": + result = await project.scan_cpp_classes( + conn, + filter_blueprintable=arguments.get("filter_blueprintable", False), + ) + # Scene Manipulation + elif name == "spawn_actor": + result = await scene.spawn_actor( + conn, + class_name=arguments["class_name"], + location=arguments["location"], + rotation=arguments.get("rotation"), + properties=arguments.get("properties"), + ) + elif name == "get_scene_hierarchy": + result = await scene.get_scene_hierarchy( + conn, + include_components=arguments.get("include_components", False), + ) + elif name == "modify_actor_transform": + result = await scene.modify_actor_transform( + conn, + actor_id=arguments["actor_id"], + location=arguments.get("location"), + rotation=arguments.get("rotation"), + scale=arguments.get("scale"), + ) + # Debug & Profiling + elif name == "get_console_logs": + result = await debug.get_console_logs( + conn, + filter_level=arguments.get("filter", "All"), + limit=arguments.get("limit", 100), + ) + elif name == "analyze_crash_dump": + result = await debug.analyze_crash_dump( + conn, + crash_log=arguments["crash_log"], + callstack=arguments.get("callstack"), + ) + elif name == "profile_blueprint": + result = await debug.profile_blueprint( + conn, + blueprint_path=arguments["blueprint_path"], + ) + # Blueprint Operations + elif name == "read_blueprint": + result = await blueprint.read_blueprint( + conn, + file_path=arguments["file_path"], + ) + elif name == "create_blueprint_from_cpp": + result = await blueprint.create_blueprint_from_cpp( + conn, + cpp_class=arguments["cpp_class"], + blueprint_name=arguments["blueprint_name"], + output_path=arguments["output_path"], + exposed_properties=arguments.get("exposed_properties"), + ) + elif name == "execute_python_script": + result = await blueprint.execute_python_script( + conn, + script=arguments.get("script"), + script_path=arguments.get("script_path"), + ) + else: + result = {"success": False, "error": f"Unknown tool: {name}"} + + except Exception as e: + logger.exception(f"Error executing tool {name}") + result = {"success": False, "error": str(e), "code": "EXECUTION_ERROR"} + + return [TextContent(type="text", text=str(result))] + + +async def run_server() -> None: + """Run the MCP server.""" + logger.info("Starting Unreal MCP Server...") + + async with stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +def main() -> None: + """Entry point for the MCP server.""" + logging.basicConfig(level=logging.INFO) + asyncio.run(run_server()) + + +if __name__ == "__main__": + main() diff --git a/src/unreal_mcp/tools/__init__.py b/src/unreal_mcp/tools/__init__.py new file mode 100644 index 0000000..81ff238 --- /dev/null +++ b/src/unreal_mcp/tools/__init__.py @@ -0,0 +1,25 @@ +"""MCP Tools for Unreal Engine.""" + +from unreal_mcp.tools.project import get_spawnable_classes, get_project_assets, scan_cpp_classes +from unreal_mcp.tools.scene import spawn_actor, get_scene_hierarchy, modify_actor_transform +from unreal_mcp.tools.debug import get_console_logs, analyze_crash_dump, profile_blueprint +from unreal_mcp.tools.blueprint import read_blueprint, create_blueprint_from_cpp, execute_python_script + +__all__ = [ + # Project Intelligence + "get_spawnable_classes", + "get_project_assets", + "scan_cpp_classes", + # Scene Manipulation + "spawn_actor", + "get_scene_hierarchy", + "modify_actor_transform", + # Debug & Profiling + "get_console_logs", + "analyze_crash_dump", + "profile_blueprint", + # Blueprint Operations + "read_blueprint", + "create_blueprint_from_cpp", + "execute_python_script", +] diff --git a/src/unreal_mcp/tools/__pycache__/__init__.cpython-312.pyc b/src/unreal_mcp/tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..8f6341e Binary files /dev/null and b/src/unreal_mcp/tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/unreal_mcp/tools/__pycache__/blueprint.cpython-312.pyc b/src/unreal_mcp/tools/__pycache__/blueprint.cpython-312.pyc new file mode 100644 index 0000000..1d5b3f3 Binary files /dev/null and b/src/unreal_mcp/tools/__pycache__/blueprint.cpython-312.pyc differ diff --git a/src/unreal_mcp/tools/__pycache__/debug.cpython-312.pyc b/src/unreal_mcp/tools/__pycache__/debug.cpython-312.pyc new file mode 100644 index 0000000..321883f Binary files /dev/null and b/src/unreal_mcp/tools/__pycache__/debug.cpython-312.pyc differ diff --git a/src/unreal_mcp/tools/__pycache__/project.cpython-312.pyc b/src/unreal_mcp/tools/__pycache__/project.cpython-312.pyc new file mode 100644 index 0000000..9a50a04 Binary files /dev/null and b/src/unreal_mcp/tools/__pycache__/project.cpython-312.pyc differ diff --git a/src/unreal_mcp/tools/__pycache__/scene.cpython-312.pyc b/src/unreal_mcp/tools/__pycache__/scene.cpython-312.pyc new file mode 100644 index 0000000..e5226e1 Binary files /dev/null and b/src/unreal_mcp/tools/__pycache__/scene.cpython-312.pyc differ diff --git a/src/unreal_mcp/tools/blueprint.py b/src/unreal_mcp/tools/blueprint.py new file mode 100644 index 0000000..6178a88 --- /dev/null +++ b/src/unreal_mcp/tools/blueprint.py @@ -0,0 +1,296 @@ +"""Blueprint Operations tools for Unreal MCP Server.""" + +from pathlib import Path +from typing import Any + +from unreal_mcp.core.unreal_connection import UnrealConnection +from unreal_mcp.core.uasset_parser import UAssetParser, UAssetParseError +from unreal_mcp.utils.logger import get_logger +from unreal_mcp.utils.validation import validate_unreal_path + +logger = get_logger(__name__) + + +async def read_blueprint( + conn: UnrealConnection, + file_path: str, +) -> dict[str, Any]: + """Read and parse a Blueprint .uasset file. + + Args: + conn: Unreal connection instance + file_path: Path to the .uasset file + + Returns: + Blueprint information including variables, functions, and events + """ + # Try editor first for full Blueprint information + if conn.is_connected or conn.connect(): + script = f""" +import unreal + +blueprint = unreal.EditorAssetLibrary.load_asset('{file_path}') +result = {{}} + +if blueprint: + # Get generated class + bp_class = blueprint.generated_class() + + result = {{ + 'class_name': blueprint.get_name(), + 'parent': bp_class.get_super_class().get_name() if bp_class else 'Unknown', + 'variables': [], + 'functions': [], + 'events': [] + }} + + # Try to get properties + if bp_class: + for prop in bp_class.properties(): + result['variables'].append({{ + 'name': prop.get_name(), + 'type': prop.get_class().get_name() + }}) + + for func in bp_class.functions(): + func_name = func.get_name() + if func_name.startswith('On'): + result['events'].append(func_name) + else: + result['functions'].append(func_name) +else: + result = {{'error': 'Blueprint not found'}} + +print(result) +""" + response = await conn.execute(script) + if response.get("success") and isinstance(response.get("data"), dict): + if "error" not in response["data"]: + return {"success": True, "data": response["data"]} + + # Fallback: parse file directly + path = Path(file_path) + if not path.exists(): + # Try to resolve from project content path + content_path = conn.get_project_content_path() + if content_path: + # Convert Unreal path to file path + if file_path.startswith("/Game/"): + rel_path = file_path.replace("/Game/", "") + path = content_path / f"{rel_path}.uasset" + + if not path.exists(): + return { + "success": False, + "error": f"Blueprint file not found: {file_path}", + "code": "FILE_NOT_FOUND", + } + + try: + parser = UAssetParser(path) + parsed = parser.parse() + return { + "success": True, + "data": { + "class_name": path.stem, + "file_path": str(path), + "names": parsed.get("names", []), + "note": "Parsed from file - limited information without editor", + }, + } + except UAssetParseError as e: + return { + "success": False, + "error": str(e), + "code": "PARSE_ERROR", + } + + +async def create_blueprint_from_cpp( + conn: UnrealConnection, + cpp_class: str, + blueprint_name: str, + output_path: str, + exposed_properties: list[str] | None = None, +) -> dict[str, Any]: + """Create a Blueprint from a C++ class. + + Args: + conn: Unreal connection instance + cpp_class: Name of the C++ class (e.g., 'AWeapon') + blueprint_name: Name for the new Blueprint + output_path: Output path in Content (e.g., '/Game/Weapons') + exposed_properties: Properties to expose in Blueprint + + Returns: + Creation result with blueprint path + """ + # Validate output path + try: + output_path = validate_unreal_path(output_path) + except ValueError as e: + return {"success": False, "error": str(e), "code": "VALIDATION_ERROR"} + + if not conn.is_connected and not conn.connect(): + # Generate a Python script for later execution + script_content = _generate_create_blueprint_script( + cpp_class, blueprint_name, output_path, exposed_properties + ) + return { + "success": False, + "error": "Editor not connected. Generated script for manual execution.", + "code": "NOT_CONNECTED", + "generated_script": script_content, + } + + script = f""" +import unreal + +# Find the C++ class +cpp_class = unreal.find_class('{cpp_class}') +if not cpp_class: + # Try with common prefixes + for prefix in ['A', 'U', '']: + cpp_class = unreal.find_class(prefix + '{cpp_class}') + if cpp_class: + break + +if cpp_class: + # Create Blueprint factory + factory = unreal.BlueprintFactory() + factory.set_editor_property('parent_class', cpp_class) + + # Create the Blueprint + asset_tools = unreal.AssetToolsHelpers.get_asset_tools() + blueprint = asset_tools.create_asset( + '{blueprint_name}', + '{output_path}', + unreal.Blueprint, + factory + ) + + if blueprint: + # Save the asset + unreal.EditorAssetLibrary.save_asset('{output_path}/{blueprint_name}') + result = {{ + 'success': True, + 'blueprint_path': '{output_path}/{blueprint_name}' + }} + else: + result = {{'success': False, 'error': 'Failed to create Blueprint'}} +else: + result = {{'success': False, 'error': 'C++ class not found: {cpp_class}'}} + +print(result) +""" + + response = await conn.execute(script) + + if response.get("success"): + data = response.get("data") + if isinstance(data, dict): + return data + return {"success": True, "data": data} + + return response + + +async def execute_python_script( + conn: UnrealConnection, + script: str | None = None, + script_path: str | None = None, +) -> dict[str, Any]: + """Execute a Python script in Unreal Editor. + + Args: + conn: Unreal connection instance + script: Python script content to execute + script_path: Path to a Python script file + + Returns: + Execution result + """ + if not script and not script_path: + return { + "success": False, + "error": "Either script or script_path must be provided", + "code": "MISSING_PARAM", + } + + if not conn.is_connected and not conn.connect(): + return { + "success": False, + "error": "Cannot execute script: Editor not connected", + "code": "NOT_CONNECTED", + } + + # If script_path provided, read the file + if script_path and not script: + path = Path(script_path) + if not path.exists(): + return { + "success": False, + "error": f"Script file not found: {script_path}", + "code": "FILE_NOT_FOUND", + } + script = path.read_text(encoding="utf-8") + + if script is None: + return { + "success": False, + "error": "No script content", + "code": "NO_SCRIPT", + } + + response = await conn.execute(script) + return response + + +def _generate_create_blueprint_script( + cpp_class: str, + blueprint_name: str, + output_path: str, + exposed_properties: list[str] | None = None, +) -> str: + """Generate a Python script for creating a Blueprint. + + This is used when the editor is not connected to provide + a script that can be run later. + """ + props_comment = "" + if exposed_properties: + props_comment = f"# Exposed properties: {', '.join(exposed_properties)}\n" + + return f"""# Auto-generated script to create Blueprint from C++ class +# Run this script in Unreal Editor's Python console + +import unreal + +{props_comment} +cpp_class = unreal.find_class('{cpp_class}') +if not cpp_class: + for prefix in ['A', 'U', '']: + cpp_class = unreal.find_class(prefix + '{cpp_class}') + if cpp_class: + break + +if cpp_class: + factory = unreal.BlueprintFactory() + factory.set_editor_property('parent_class', cpp_class) + + asset_tools = unreal.AssetToolsHelpers.get_asset_tools() + blueprint = asset_tools.create_asset( + '{blueprint_name}', + '{output_path}', + unreal.Blueprint, + factory + ) + + if blueprint: + unreal.EditorAssetLibrary.save_asset('{output_path}/{blueprint_name}') + unreal.log(f'Created Blueprint: {output_path}/{blueprint_name}') + else: + unreal.log_error('Failed to create Blueprint') +else: + unreal.log_error('C++ class not found: {cpp_class}') +""" diff --git a/src/unreal_mcp/tools/debug.py b/src/unreal_mcp/tools/debug.py new file mode 100644 index 0000000..bdef47e --- /dev/null +++ b/src/unreal_mcp/tools/debug.py @@ -0,0 +1,206 @@ +"""Debug & Profiling tools for Unreal MCP Server.""" + +import re +from typing import Any + +from unreal_mcp.core.unreal_connection import UnrealConnection +from unreal_mcp.utils.logger import get_logger + +logger = get_logger(__name__) + + +async def get_console_logs( + conn: UnrealConnection, + filter_level: str = "All", + limit: int = 100, +) -> dict[str, Any]: + """Get Unreal Engine console logs. + + Args: + conn: Unreal connection instance + filter_level: Filter by log level ('All', 'Error', 'Warning') + limit: Maximum number of logs to return + + Returns: + List of log entries + """ + if not conn.is_connected and not conn.connect(): + return { + "success": False, + "error": "Cannot get logs: Editor not connected", + "code": "NOT_CONNECTED", + } + + filter_code = "" + if filter_level == "Error": + filter_code = "if 'Error' in line or 'error' in line:" + elif filter_level == "Warning": + filter_code = "if 'Warning' in line or 'warning' in line:" + + script = f""" +import unreal + +# Note: Direct log access requires custom implementation +# This is a placeholder - actual implementation depends on log capture setup +result = {{ + 'logs': [], + 'note': 'Log capture requires additional setup in Unreal' +}} + +print(result) +""" + + response = await conn.execute(script) + + if response.get("success"): + return {"success": True, "data": response.get("data", {"logs": []})} + + return response + + +async def analyze_crash_dump( + conn: UnrealConnection, + crash_log: str, + callstack: str | None = None, +) -> dict[str, Any]: + """Analyze a crash dump and suggest fixes. + + Args: + conn: Unreal connection instance + crash_log: The crash log content + callstack: Optional callstack + + Returns: + Analysis with root cause and fix suggestions + """ + analysis: dict[str, Any] = { + "root_cause": "Unknown", + "file": None, + "line": None, + "fix_suggestion": None, + "severity": "HIGH", + } + + # Analyze crash log for common patterns + log_lower = crash_log.lower() + + # Null pointer dereference + if "null" in log_lower or "nullptr" in log_lower or "access violation" in log_lower: + analysis["root_cause"] = "Null pointer dereference" + analysis["fix_suggestion"] = ( + "Add null checks before accessing the pointer:\n" + "if (Ptr && IsValid(Ptr)) { /* use Ptr */ }" + ) + + # Array out of bounds + elif "array" in log_lower and ("bounds" in log_lower or "index" in log_lower): + analysis["root_cause"] = "Array index out of bounds" + analysis["fix_suggestion"] = ( + "Check array bounds before accessing:\n" + "if (Array.IsValidIndex(Index)) { /* access Array[Index] */ }" + ) + + # Stack overflow + elif "stack overflow" in log_lower or "recursive" in log_lower: + analysis["root_cause"] = "Stack overflow (likely infinite recursion)" + analysis["fix_suggestion"] = ( + "Check for recursive function calls and add base case or depth limit" + ) + + # Memory allocation failure + elif "out of memory" in log_lower or "allocation" in log_lower: + analysis["root_cause"] = "Memory allocation failure" + analysis["fix_suggestion"] = ( + "Check for memory leaks, reduce memory usage, or increase available memory" + ) + + # Division by zero + elif "divide" in log_lower and "zero" in log_lower: + analysis["root_cause"] = "Division by zero" + analysis["fix_suggestion"] = ( + "Check divisor before division:\n" + "if (FMath::Abs(Divisor) > SMALL_NUMBER) { Result = Value / Divisor; }" + ) + + # Extract file and line info from callstack if available + full_log = crash_log + (callstack or "") + + # Pattern: filename.cpp:123 or filename.cpp(123) + file_line_pattern = r"(\w+\.(?:cpp|h)):?\(?(\d+)\)?" + matches = re.findall(file_line_pattern, full_log) + if matches: + # Take the first match that's likely user code (not Engine code) + for filename, line in matches: + if not any(eng in filename.lower() for eng in ["engine", "core", "runtime"]): + analysis["file"] = filename + analysis["line"] = int(line) + break + + return {"success": True, "data": analysis} + + +async def profile_blueprint( + conn: UnrealConnection, + blueprint_path: str, +) -> dict[str, Any]: + """Profile a Blueprint's performance. + + Args: + conn: Unreal connection instance + blueprint_path: Path to the Blueprint asset + + Returns: + Performance analysis with hotspots and optimization suggestions + """ + if not conn.is_connected and not conn.connect(): + # Provide static analysis fallback + return { + "success": True, + "data": { + "blueprint_path": blueprint_path, + "note": "Runtime profiling requires editor connection. Static analysis only.", + "static_analysis": { + "recommendations": [ + "Consider using C++ for performance-critical logic", + "Avoid heavy operations in Event Tick", + "Use async loading for large assets", + "Cache component references instead of finding each frame", + ] + }, + }, + } + + script = f""" +import unreal + +# Note: Blueprint profiling requires PIE (Play in Editor) session +# and Blueprint Profiler plugin enabled + +result = {{ + 'blueprint_path': '{blueprint_path}', + 'note': 'For runtime profiling, run the game and check Blueprint Profiler', + 'static_analysis': {{ + 'tick_functions': 0, + 'expensive_nodes': [], + 'recommendations': [] + }} +}} + +# Try to load and analyze the Blueprint +asset = unreal.EditorAssetLibrary.load_asset('{blueprint_path}') +if asset: + # Basic static analysis + result['static_analysis']['asset_loaded'] = True +else: + result['static_analysis']['asset_loaded'] = False + result['static_analysis']['recommendations'].append('Blueprint not found at path') + +print(result) +""" + + response = await conn.execute(script) + + if response.get("success"): + return {"success": True, "data": response.get("data", {})} + + return response diff --git a/src/unreal_mcp/tools/project.py b/src/unreal_mcp/tools/project.py new file mode 100644 index 0000000..5030920 --- /dev/null +++ b/src/unreal_mcp/tools/project.py @@ -0,0 +1,191 @@ +"""Project Intelligence tools for Unreal MCP Server.""" + +from typing import Any + +from unreal_mcp.core.unreal_connection import UnrealConnection +from unreal_mcp.core.uasset_parser import scan_content_folder +from unreal_mcp.utils.logger import get_logger + +logger = get_logger(__name__) + + +async def get_spawnable_classes( + conn: UnrealConnection, + filter_type: str = "all", +) -> dict[str, Any]: + """Get all spawnable classes in the project. + + Args: + conn: Unreal connection instance + filter_type: Filter by type ('all', 'blueprint', 'native') + + Returns: + Dictionary with native_actors, blueprint_actors, and components + """ + result: dict[str, Any] = { + "native_actors": [], + "blueprint_actors": [], + "components": [], + } + + # Try to get from editor first + if conn.is_connected or conn.connect(): + script = """ +import unreal + +result = { + 'native_actors': [], + 'blueprint_actors': [], + 'components': [] +} + +# Get all Blueprint classes +asset_registry = unreal.AssetRegistryHelpers.get_asset_registry() +blueprint_filter = unreal.ARFilter( + class_names=['Blueprint'], + recursive_classes=True +) +blueprint_assets = asset_registry.get_assets(blueprint_filter) + +for asset in blueprint_assets: + asset_data = asset.get_asset() + if asset_data: + bp_class = asset_data.generated_class() + if bp_class and bp_class.is_child_of(unreal.Actor): + result['blueprint_actors'].append({ + 'name': asset.asset_name, + 'path': str(asset.package_name) + }) + +print(result) +""" + response = await conn.execute(script) + if response.get("success"): + data = response.get("data", {}) + if isinstance(data, dict): + result = data + + # Fallback: scan Content folder + else: + content_path = conn.get_project_content_path() + if content_path: + assets = scan_content_folder(content_path) + for asset in assets: + if asset["type"] == "Blueprint" and asset["name"].startswith("BP_"): + result["blueprint_actors"].append({ + "name": asset["name"], + "path": asset.get("content_path", asset["path"]), + }) + + # Apply filter + if filter_type == "blueprint": + result["native_actors"] = [] + elif filter_type == "native": + result["blueprint_actors"] = [] + + return {"success": True, "data": result} + + +async def get_project_assets( + conn: UnrealConnection, + asset_type: str | None = None, +) -> dict[str, Any]: + """Get inventory of all project assets. + + Args: + conn: Unreal connection instance + asset_type: Optional filter by asset type + + Returns: + Dictionary with asset counts and by_folder breakdown + """ + result: dict[str, Any] = { + "total": 0, + "by_type": {}, + "by_folder": {}, + } + + content_path = conn.get_project_content_path() + if not content_path: + return { + "success": False, + "error": "Project path not configured", + "code": "NO_PROJECT_PATH", + } + + assets = scan_content_folder(content_path) + + for asset in assets: + # Count by type + atype = asset["type"] + if asset_type and atype != asset_type: + continue + + result["by_type"][atype] = result["by_type"].get(atype, 0) + 1 + result["total"] += 1 + + # Group by folder + content_path_str = asset.get("content_path", "") + if content_path_str: + parts = content_path_str.split("/") + if len(parts) > 2: + folder = parts[2] # /Game/Folder/... + if folder not in result["by_folder"]: + result["by_folder"][folder] = [] + result["by_folder"][folder].append(asset["name"]) + + return {"success": True, "data": result} + + +async def scan_cpp_classes( + conn: UnrealConnection, + filter_blueprintable: bool = False, +) -> dict[str, Any]: + """Scan C++ classes in the project Source folder. + + Args: + conn: Unreal connection instance + filter_blueprintable: Only return Blueprintable classes + + Returns: + List of C++ class information + """ + classes: list[dict[str, Any]] = [] + + source_path = conn.get_project_source_path() + if not source_path: + return { + "success": False, + "error": "Source path not found", + "code": "NO_SOURCE_PATH", + } + + # Scan header files + import re + + for header_file in source_path.rglob("*.h"): + try: + content = header_file.read_text(encoding="utf-8", errors="ignore") + + # Find UCLASS declarations + uclass_pattern = r"UCLASS\s*\(([^)]*)\)\s*class\s+\w*_API\s+(\w+)\s*:\s*public\s+(\w+)" + matches = re.findall(uclass_pattern, content) + + for specifiers, class_name, parent_class in matches: + is_blueprintable = "Blueprintable" in specifiers + + if filter_blueprintable and not is_blueprintable: + continue + + classes.append({ + "name": class_name, + "file": str(header_file.relative_to(source_path)), + "parent": parent_class, + "is_blueprintable": is_blueprintable, + "specifiers": specifiers.strip(), + }) + + except Exception as e: + logger.debug(f"Error parsing {header_file}: {e}") + + return {"success": True, "data": classes} diff --git a/src/unreal_mcp/tools/scene.py b/src/unreal_mcp/tools/scene.py new file mode 100644 index 0000000..35edf77 --- /dev/null +++ b/src/unreal_mcp/tools/scene.py @@ -0,0 +1,238 @@ +"""Scene Manipulation tools for Unreal MCP Server.""" + +from typing import Any + +from unreal_mcp.core.unreal_connection import UnrealConnection +from unreal_mcp.utils.logger import get_logger +from unreal_mcp.utils.validation import validate_class_name, validate_location, validate_rotation + +logger = get_logger(__name__) + + +async def spawn_actor( + conn: UnrealConnection, + class_name: str, + location: list[float], + rotation: list[float] | None = None, + properties: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Spawn an actor in the current level. + + Args: + conn: Unreal connection instance + class_name: Name of the class to spawn + location: Spawn location [X, Y, Z] + rotation: Optional spawn rotation [Pitch, Yaw, Roll] + properties: Optional initial property values + + Returns: + Dictionary with spawn result + """ + # Validate inputs + try: + class_name = validate_class_name(class_name) + loc = validate_location(location) + rot = validate_rotation(rotation) if rotation else (0, 0, 0) + except ValueError as e: + return {"success": False, "error": str(e), "code": "VALIDATION_ERROR"} + + # Check connection + if not conn.is_connected and not conn.connect(): + return { + "success": False, + "error": "Cannot spawn actor: Editor not connected", + "code": "NOT_CONNECTED", + } + + # Build spawn script + props_code = "" + if properties: + for prop_name, prop_value in properties.items(): + if isinstance(prop_value, str): + props_code += f" actor.set_editor_property('{prop_name}', '{prop_value}')\n" + else: + props_code += f" actor.set_editor_property('{prop_name}', {prop_value})\n" + + script = f""" +import unreal + +# Find the class +actor_class = unreal.EditorAssetLibrary.find_asset_data('/Game/**/{class_name}*').get_asset() +if not actor_class: + # Try as a native class + actor_class = getattr(unreal, '{class_name}', None) + +if actor_class: + location = unreal.Vector({loc[0]}, {loc[1]}, {loc[2]}) + rotation = unreal.Rotator({rot[0]}, {rot[1]}, {rot[2]}) + + actor = unreal.EditorLevelLibrary.spawn_actor_from_class( + actor_class, + location, + rotation + ) + + if actor: +{props_code if props_code else " pass"} + result = {{ + 'success': True, + 'actor_id': actor.get_name(), + 'location': [{loc[0]}, {loc[1]}, {loc[2]}] + }} + else: + result = {{'success': False, 'error': 'Failed to spawn actor'}} +else: + result = {{'success': False, 'error': 'Class not found: {class_name}'}} + +print(result) +""" + + response = await conn.execute(script) + + if response.get("success"): + data = response.get("data") + if isinstance(data, dict): + return data + return {"success": True, "data": data} + + return response + + +async def get_scene_hierarchy( + conn: UnrealConnection, + include_components: bool = False, +) -> dict[str, Any]: + """Get the hierarchy of actors in the current level. + + Args: + conn: Unreal connection instance + include_components: Include component details + + Returns: + Dictionary with level name and actors list + """ + if not conn.is_connected and not conn.connect(): + return { + "success": False, + "error": "Cannot get scene: Editor not connected", + "code": "NOT_CONNECTED", + } + + components_code = "" + if include_components: + components_code = """ + components = actor.get_components_by_class(unreal.ActorComponent) + actor_info['components'] = [comp.get_name() for comp in components] +""" + + script = f""" +import unreal + +actors = unreal.EditorLevelLibrary.get_all_level_actors() +level = unreal.EditorLevelLibrary.get_editor_world() + +result = {{ + 'level': level.get_name() if level else 'Unknown', + 'actors': [] +}} + +for actor in actors: + location = actor.get_actor_location() + actor_info = {{ + 'id': actor.get_name(), + 'class': actor.get_class().get_name(), + 'location': [location.x, location.y, location.z], + }} +{components_code} + result['actors'].append(actor_info) + +print(result) +""" + + response = await conn.execute(script) + + if response.get("success"): + data = response.get("data") + if isinstance(data, dict): + return {"success": True, "data": data} + return {"success": True, "data": data} + + return response + + +async def modify_actor_transform( + conn: UnrealConnection, + actor_id: str, + location: list[float] | None = None, + rotation: list[float] | None = None, + scale: list[float] | None = None, +) -> dict[str, Any]: + """Modify the transform of an actor. + + Args: + conn: Unreal connection instance + actor_id: ID or name of the actor to modify + location: New location [X, Y, Z] + rotation: New rotation [Pitch, Yaw, Roll] + scale: New scale [X, Y, Z] + + Returns: + Dictionary with modification result + """ + if not conn.is_connected and not conn.connect(): + return { + "success": False, + "error": "Cannot modify actor: Editor not connected", + "code": "NOT_CONNECTED", + } + + # Build modification code + mods = [] + if location: + loc = validate_location(location) + mods.append(f"actor.set_actor_location(unreal.Vector({loc[0]}, {loc[1]}, {loc[2]}), False, False)") + if rotation: + rot = validate_rotation(rotation) + mods.append(f"actor.set_actor_rotation(unreal.Rotator({rot[0]}, {rot[1]}, {rot[2]}), False)") + if scale: + if len(scale) != 3: + return {"success": False, "error": "Scale must have 3 components", "code": "VALIDATION_ERROR"} + mods.append(f"actor.set_actor_scale3d(unreal.Vector({scale[0]}, {scale[1]}, {scale[2]}))") + + if not mods: + return {"success": False, "error": "No transform changes specified", "code": "NO_CHANGES"} + + mods_code = "\n ".join(mods) + + script = f""" +import unreal + +actors = unreal.EditorLevelLibrary.get_all_level_actors() +actor = None + +for a in actors: + if a.get_name() == '{actor_id}': + actor = a + break + +if actor: + try: + {mods_code} + result = {{'success': True, 'actor_id': '{actor_id}'}} + except Exception as e: + result = {{'success': False, 'error': str(e)}} +else: + result = {{'success': False, 'error': 'Actor not found: {actor_id}'}} + +print(result) +""" + + response = await conn.execute(script) + + if response.get("success"): + data = response.get("data") + if isinstance(data, dict): + return data + return {"success": True, "data": data} + + return response diff --git a/src/unreal_mcp/utils/__init__.py b/src/unreal_mcp/utils/__init__.py new file mode 100644 index 0000000..791d423 --- /dev/null +++ b/src/unreal_mcp/utils/__init__.py @@ -0,0 +1,10 @@ +"""Utility modules for Unreal MCP Server.""" + +from unreal_mcp.utils.logger import get_logger +from unreal_mcp.utils.validation import validate_path, validate_class_name + +__all__ = [ + "get_logger", + "validate_path", + "validate_class_name", +] diff --git a/src/unreal_mcp/utils/__pycache__/__init__.cpython-312.pyc b/src/unreal_mcp/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..9a31a7f Binary files /dev/null and b/src/unreal_mcp/utils/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/unreal_mcp/utils/__pycache__/logger.cpython-312.pyc b/src/unreal_mcp/utils/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000..acfcab1 Binary files /dev/null and b/src/unreal_mcp/utils/__pycache__/logger.cpython-312.pyc differ diff --git a/src/unreal_mcp/utils/__pycache__/validation.cpython-312.pyc b/src/unreal_mcp/utils/__pycache__/validation.cpython-312.pyc new file mode 100644 index 0000000..d1b8a57 Binary files /dev/null and b/src/unreal_mcp/utils/__pycache__/validation.cpython-312.pyc differ diff --git a/src/unreal_mcp/utils/config.py b/src/unreal_mcp/utils/config.py new file mode 100644 index 0000000..4e96755 --- /dev/null +++ b/src/unreal_mcp/utils/config.py @@ -0,0 +1,32 @@ +"""Configuration settings for Unreal MCP Server.""" + +from pathlib import Path + +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # Unreal Project + ue_project_path: Path | None = None + + # Python Remote Execution - TCP Commands + ue_command_host: str = "localhost" + ue_command_port: int = 6776 # Default Unreal TCP command port + + # Python Remote Execution - UDP Multicast Discovery + ue_multicast_group: str = "239.0.0.1" + ue_multicast_port: int = 6766 # Default Unreal multicast port + ue_multicast_bind: str = "0.0.0.0" + ue_multicast_ttl: int = 0 + + # Logging + log_level: str = "INFO" + + model_config = { + "env_prefix": "", + "env_file": ".env", + "env_file_encoding": "utf-8", + "extra": "ignore", + } diff --git a/src/unreal_mcp/utils/logger.py b/src/unreal_mcp/utils/logger.py new file mode 100644 index 0000000..4eae0ef --- /dev/null +++ b/src/unreal_mcp/utils/logger.py @@ -0,0 +1,45 @@ +"""Logging utilities for Unreal MCP Server.""" + +import logging +import sys +from typing import Any + + +def get_logger(name: str, level: int | str = logging.INFO) -> logging.Logger: + """Get a configured logger instance. + + Args: + name: Logger name (usually __name__) + level: Logging level + + Returns: + Configured logger instance + """ + logger = logging.getLogger(name) + + if not logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter( + logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger.addHandler(handler) + + if isinstance(level, str): + level = getattr(logging, level.upper(), logging.INFO) + logger.setLevel(level) + + return logger + + +def log_tool_call(logger: logging.Logger, tool_name: str, arguments: dict[str, Any]) -> None: + """Log a tool call for debugging. + + Args: + logger: Logger instance + tool_name: Name of the tool being called + arguments: Tool arguments + """ + logger.debug(f"Tool call: {tool_name} with args: {arguments}") diff --git a/src/unreal_mcp/utils/validation.py b/src/unreal_mcp/utils/validation.py new file mode 100644 index 0000000..fe16ae0 --- /dev/null +++ b/src/unreal_mcp/utils/validation.py @@ -0,0 +1,112 @@ +"""Validation utilities for Unreal MCP Server.""" + +import re +from pathlib import Path + + +def validate_path(path: str | Path, must_exist: bool = False) -> Path: + """Validate and normalize a file path. + + Args: + path: Path to validate + must_exist: If True, check that the path exists + + Returns: + Normalized Path object + + Raises: + ValueError: If path is invalid or doesn't exist (when must_exist=True) + """ + path = Path(path) + + if must_exist and not path.exists(): + raise ValueError(f"Path does not exist: {path}") + + return path + + +def validate_class_name(name: str) -> str: + """Validate an Unreal class name. + + Args: + name: Class name to validate (e.g., 'BP_Enemy', 'AWeapon') + + Returns: + Validated class name + + Raises: + ValueError: If class name is invalid + """ + # Unreal class names should be alphanumeric with underscores + # Typically prefixed with A (Actor), U (Object), F (Struct), E (Enum) + # Or BP_ for Blueprints + pattern = r"^[A-Z][A-Za-z0-9_]*$" + + if not re.match(pattern, name): + raise ValueError( + f"Invalid class name: {name}. " + "Must start with uppercase letter and contain only alphanumeric characters and underscores." + ) + + return name + + +def validate_unreal_path(path: str) -> str: + """Validate an Unreal content path. + + Args: + path: Unreal path (e.g., '/Game/Weapons/BP_Rifle') + + Returns: + Validated path + + Raises: + ValueError: If path is invalid + """ + if not path.startswith("/"): + raise ValueError(f"Unreal path must start with '/': {path}") + + # Common valid prefixes + valid_prefixes = ["/Game/", "/Engine/", "/Script/"] + if not any(path.startswith(prefix) for prefix in valid_prefixes): + raise ValueError( + f"Unreal path must start with one of {valid_prefixes}: {path}" + ) + + return path + + +def validate_location(location: list[float]) -> tuple[float, float, float]: + """Validate a 3D location. + + Args: + location: List of [X, Y, Z] coordinates + + Returns: + Tuple of (X, Y, Z) + + Raises: + ValueError: If location is invalid + """ + if len(location) != 3: + raise ValueError(f"Location must have 3 components [X, Y, Z], got {len(location)}") + + return tuple(float(v) for v in location) # type: ignore + + +def validate_rotation(rotation: list[float]) -> tuple[float, float, float]: + """Validate a 3D rotation. + + Args: + rotation: List of [Pitch, Yaw, Roll] angles in degrees + + Returns: + Tuple of (Pitch, Yaw, Roll) + + Raises: + ValueError: If rotation is invalid + """ + if len(rotation) != 3: + raise ValueError(f"Rotation must have 3 components [Pitch, Yaw, Roll], got {len(rotation)}") + + return tuple(float(v) for v in rotation) # type: ignore diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7670edf --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for Unreal MCP Server.""" diff --git a/tests/__pycache__/__init__.cpython-312.pyc b/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..e547c2c Binary files /dev/null and b/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..58db3bb Binary files /dev/null and b/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..e2244f8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +"""Pytest fixtures for Unreal MCP Server tests.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock, AsyncMock + +from unreal_mcp.core.unreal_connection import UnrealConnection + + +@pytest.fixture +def mock_connection() -> MagicMock: + """Create a mock UnrealConnection for testing.""" + conn = MagicMock(spec=UnrealConnection) + conn.is_connected = False + conn.connect.return_value = False + conn.execute = AsyncMock(return_value={"success": False, "error": "Not connected"}) + conn.get_project_content_path.return_value = None + conn.get_project_source_path.return_value = None + return conn + + +@pytest.fixture +def connected_mock_connection() -> MagicMock: + """Create a mock UnrealConnection that simulates being connected.""" + conn = MagicMock(spec=UnrealConnection) + conn.is_connected = True + conn.connect.return_value = True + conn.execute = AsyncMock(return_value={"success": True, "data": {}}) + conn.get_project_content_path.return_value = None + conn.get_project_source_path.return_value = None + return conn + + +@pytest.fixture +def sample_project_path(tmp_path: Path) -> Path: + """Create a sample Unreal project structure for testing.""" + project_path = tmp_path / "TestProject" + project_path.mkdir() + + # Create Content folder with sample assets + content_path = project_path / "Content" + content_path.mkdir() + + blueprints_path = content_path / "Blueprints" + blueprints_path.mkdir() + + # Create dummy .uasset files + (blueprints_path / "BP_Enemy.uasset").write_bytes(b"\xc1\x83\x2a\x9e" + b"\x00" * 100) + (blueprints_path / "BP_Player.uasset").write_bytes(b"\xc1\x83\x2a\x9e" + b"\x00" * 100) + + # Create Source folder with sample C++ files + source_path = project_path / "Source" / "TestProject" + source_path.mkdir(parents=True) + + (source_path / "TestActor.h").write_text(""" +#pragma once + +#include "CoreMinimal.h" +#include "GameFramework/Actor.h" +#include "TestActor.generated.h" + +UCLASS(Blueprintable) +class TESTPROJECT_API ATestActor : public AActor +{ + GENERATED_BODY() + +public: + UPROPERTY(EditAnywhere, BlueprintReadWrite) + float Health = 100.0f; + + UFUNCTION(BlueprintCallable) + void TakeDamage(float Amount); +}; +""") + + return project_path + + +@pytest.fixture +def mock_connection_with_project( + mock_connection: MagicMock, + sample_project_path: Path, +) -> MagicMock: + """Create a mock connection with a sample project path.""" + mock_connection.project_path = sample_project_path + mock_connection.get_project_content_path.return_value = sample_project_path / "Content" + mock_connection.get_project_source_path.return_value = sample_project_path / "Source" + return mock_connection diff --git a/tests/test_core/__init__.py b/tests/test_core/__init__.py new file mode 100644 index 0000000..3447409 --- /dev/null +++ b/tests/test_core/__init__.py @@ -0,0 +1 @@ +"""Tests for core modules.""" diff --git a/tests/test_core/__pycache__/__init__.cpython-312.pyc b/tests/test_core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..430dd48 Binary files /dev/null and b/tests/test_core/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/test_core/__pycache__/test_uasset_parser.cpython-312-pytest-9.0.2.pyc b/tests/test_core/__pycache__/test_uasset_parser.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..d32a6cf Binary files /dev/null and b/tests/test_core/__pycache__/test_uasset_parser.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_core/__pycache__/test_validation.cpython-312-pytest-9.0.2.pyc b/tests/test_core/__pycache__/test_validation.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..894ebe9 Binary files /dev/null and b/tests/test_core/__pycache__/test_validation.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_core/test_uasset_parser.py b/tests/test_core/test_uasset_parser.py new file mode 100644 index 0000000..c2cb343 --- /dev/null +++ b/tests/test_core/test_uasset_parser.py @@ -0,0 +1,84 @@ +"""Tests for uasset parser.""" + +import pytest +from pathlib import Path + +from unreal_mcp.core.uasset_parser import ( + UAssetParser, + UAssetParseError, + scan_content_folder, +) + + +def test_uasset_parser_get_asset_info() -> None: + """Test getting asset info from path.""" + # Test Blueprint + info = UAssetParser.get_asset_info_from_path(Path("Content/BP_Enemy.uasset")) + assert info["name"] == "BP_Enemy" + assert info["type"] == "Blueprint" + + # Test Material + info = UAssetParser.get_asset_info_from_path(Path("Content/M_Wood.uasset")) + assert info["name"] == "M_Wood" + assert info["type"] == "Material" + + # Test StaticMesh + info = UAssetParser.get_asset_info_from_path(Path("Content/SM_Chair.uasset")) + assert info["name"] == "SM_Chair" + assert info["type"] == "StaticMesh" + + # Test Unknown + info = UAssetParser.get_asset_info_from_path(Path("Content/SomeAsset.uasset")) + assert info["name"] == "SomeAsset" + assert info["type"] == "Unknown" + + +def test_uasset_parser_file_not_found() -> None: + """Test parser with non-existent file.""" + parser = UAssetParser(Path("nonexistent.uasset")) + + with pytest.raises(UAssetParseError, match="File not found"): + parser.parse() + + +def test_uasset_parser_invalid_file(tmp_path: Path) -> None: + """Test parser with invalid file.""" + invalid_file = tmp_path / "invalid.uasset" + invalid_file.write_bytes(b"not a valid uasset") + + parser = UAssetParser(invalid_file) + + with pytest.raises(UAssetParseError, match="Invalid magic number"): + parser.parse() + + +def test_uasset_parser_valid_header(tmp_path: Path) -> None: + """Test parser with valid header.""" + # Create a minimal valid .uasset file + valid_file = tmp_path / "BP_Test.uasset" + # Magic number + minimal header + valid_file.write_bytes(b"\xc1\x83\x2a\x9e" + b"\x00" * 100) + + parser = UAssetParser(valid_file) + result = parser.parse() + + assert result["success"] is True + assert "header" in result + assert result["header"]["magic"] == "0x9e2a83c1" + + +def test_scan_content_folder(sample_project_path: Path) -> None: + """Test scanning content folder.""" + content_path = sample_project_path / "Content" + assets = scan_content_folder(content_path) + + assert len(assets) == 2 + names = [a["name"] for a in assets] + assert "BP_Enemy" in names + assert "BP_Player" in names + + +def test_scan_content_folder_nonexistent(tmp_path: Path) -> None: + """Test scanning non-existent folder.""" + assets = scan_content_folder(tmp_path / "nonexistent") + assert assets == [] diff --git a/tests/test_core/test_validation.py b/tests/test_core/test_validation.py new file mode 100644 index 0000000..b30807c --- /dev/null +++ b/tests/test_core/test_validation.py @@ -0,0 +1,88 @@ +"""Tests for validation utilities.""" + +import pytest +from pathlib import Path + +from unreal_mcp.utils.validation import ( + validate_path, + validate_class_name, + validate_unreal_path, + validate_location, + validate_rotation, +) + + +def test_validate_path(tmp_path: Path) -> None: + """Test path validation.""" + # Valid path + test_file = tmp_path / "test.txt" + test_file.write_text("test") + + result = validate_path(test_file) + assert result == test_file + + # Path with must_exist=True + result = validate_path(test_file, must_exist=True) + assert result == test_file + + # Non-existent path with must_exist=True + with pytest.raises(ValueError, match="does not exist"): + validate_path(tmp_path / "nonexistent.txt", must_exist=True) + + +def test_validate_class_name() -> None: + """Test class name validation.""" + # Valid names + assert validate_class_name("AWeapon") == "AWeapon" + assert validate_class_name("BP_Enemy") == "BP_Enemy" + assert validate_class_name("UHealthComponent") == "UHealthComponent" + assert validate_class_name("FVector") == "FVector" + + # Invalid names + with pytest.raises(ValueError, match="Invalid class name"): + validate_class_name("lowercase") + + with pytest.raises(ValueError, match="Invalid class name"): + validate_class_name("Has Space") + + with pytest.raises(ValueError, match="Invalid class name"): + validate_class_name("123Number") + + +def test_validate_unreal_path() -> None: + """Test Unreal content path validation.""" + # Valid paths + assert validate_unreal_path("/Game/Weapons/BP_Rifle") == "/Game/Weapons/BP_Rifle" + assert validate_unreal_path("/Engine/Content/Test") == "/Engine/Content/Test" + + # Invalid paths + with pytest.raises(ValueError, match="must start with"): + validate_unreal_path("Game/Weapons") + + with pytest.raises(ValueError, match="must start with one of"): + validate_unreal_path("/Invalid/Path") + + +def test_validate_location() -> None: + """Test location validation.""" + # Valid locations + assert validate_location([0, 0, 0]) == (0.0, 0.0, 0.0) + assert validate_location([100.5, -200, 50.25]) == (100.5, -200.0, 50.25) + + # Invalid locations + with pytest.raises(ValueError, match="must have 3 components"): + validate_location([0, 0]) + + with pytest.raises(ValueError, match="must have 3 components"): + validate_location([0, 0, 0, 0]) + + +def test_validate_rotation() -> None: + """Test rotation validation.""" + # Valid rotations + assert validate_rotation([0, 0, 0]) == (0.0, 0.0, 0.0) + assert validate_rotation([45, 90, -30]) == (45.0, 90.0, -30.0) + + # Invalid rotations + with pytest.raises(ValueError, match="must have 3 components"): + validate_rotation([0, 0]) diff --git a/tests/test_tools/__init__.py b/tests/test_tools/__init__.py new file mode 100644 index 0000000..37d36b5 --- /dev/null +++ b/tests/test_tools/__init__.py @@ -0,0 +1 @@ +"""Tests for MCP tools.""" diff --git a/tests/test_tools/__pycache__/__init__.cpython-312.pyc b/tests/test_tools/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1b9eddc Binary files /dev/null and b/tests/test_tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/tests/test_tools/__pycache__/test_debug.cpython-312-pytest-9.0.2.pyc b/tests/test_tools/__pycache__/test_debug.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..ddffbea Binary files /dev/null and b/tests/test_tools/__pycache__/test_debug.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_tools/__pycache__/test_project.cpython-312-pytest-9.0.2.pyc b/tests/test_tools/__pycache__/test_project.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..f0c1469 Binary files /dev/null and b/tests/test_tools/__pycache__/test_project.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/test_tools/test_debug.py b/tests/test_tools/test_debug.py new file mode 100644 index 0000000..3a930a6 --- /dev/null +++ b/tests/test_tools/test_debug.py @@ -0,0 +1,66 @@ +"""Tests for debug tools.""" + +import pytest +from unittest.mock import MagicMock + +from unreal_mcp.tools.debug import analyze_crash_dump + + +@pytest.mark.asyncio +async def test_analyze_crash_dump_null_pointer( + mock_connection: MagicMock, +) -> None: + """Test crash dump analysis for null pointer.""" + crash_log = """ + Access violation - code c0000005 (first/second chance not available) + Unreal Engine is exiting due to D3D device being lost. + NullPointerException at Weapon.cpp:145 + """ + + result = await analyze_crash_dump(mock_connection, crash_log) + + assert result["success"] is True + assert "Null pointer" in result["data"]["root_cause"] + assert result["data"]["file"] == "Weapon.cpp" + assert result["data"]["line"] == 145 + + +@pytest.mark.asyncio +async def test_analyze_crash_dump_array_bounds( + mock_connection: MagicMock, +) -> None: + """Test crash dump analysis for array out of bounds.""" + crash_log = "Array index out of bounds: index 10 from array of size 5" + + result = await analyze_crash_dump(mock_connection, crash_log) + + assert result["success"] is True + assert "Array" in result["data"]["root_cause"] + assert "IsValidIndex" in result["data"]["fix_suggestion"] + + +@pytest.mark.asyncio +async def test_analyze_crash_dump_stack_overflow( + mock_connection: MagicMock, +) -> None: + """Test crash dump analysis for stack overflow.""" + crash_log = "Stack overflow error in recursive function call" + + result = await analyze_crash_dump(mock_connection, crash_log) + + assert result["success"] is True + assert "Stack overflow" in result["data"]["root_cause"] + + +@pytest.mark.asyncio +async def test_analyze_crash_dump_division_zero( + mock_connection: MagicMock, +) -> None: + """Test crash dump analysis for division by zero.""" + crash_log = "Divide by zero error in calculation" + + result = await analyze_crash_dump(mock_connection, crash_log) + + assert result["success"] is True + assert "Division by zero" in result["data"]["root_cause"] + assert "SMALL_NUMBER" in result["data"]["fix_suggestion"] diff --git a/tests/test_tools/test_project.py b/tests/test_tools/test_project.py new file mode 100644 index 0000000..e46eaf4 --- /dev/null +++ b/tests/test_tools/test_project.py @@ -0,0 +1,100 @@ +"""Tests for project intelligence tools.""" + +import pytest +from pathlib import Path +from unittest.mock import MagicMock + +from unreal_mcp.tools.project import ( + get_spawnable_classes, + get_project_assets, + scan_cpp_classes, +) + + +@pytest.mark.asyncio +async def test_get_spawnable_classes_offline( + mock_connection_with_project: MagicMock, +) -> None: + """Test get_spawnable_classes in offline mode.""" + result = await get_spawnable_classes(mock_connection_with_project) + + assert result["success"] is True + assert "data" in result + assert "blueprint_actors" in result["data"] + # Should find the BP_Enemy and BP_Player from sample project + bp_names = [bp["name"] for bp in result["data"]["blueprint_actors"]] + assert "BP_Enemy" in bp_names + assert "BP_Player" in bp_names + + +@pytest.mark.asyncio +async def test_get_spawnable_classes_filter_blueprint( + mock_connection_with_project: MagicMock, +) -> None: + """Test get_spawnable_classes with blueprint filter.""" + result = await get_spawnable_classes( + mock_connection_with_project, + filter_type="blueprint", + ) + + assert result["success"] is True + assert result["data"]["native_actors"] == [] + + +@pytest.mark.asyncio +async def test_get_project_assets( + mock_connection_with_project: MagicMock, +) -> None: + """Test get_project_assets.""" + result = await get_project_assets(mock_connection_with_project) + + assert result["success"] is True + assert "data" in result + assert result["data"]["total"] >= 2 + assert "Blueprint" in result["data"]["by_type"] + + +@pytest.mark.asyncio +async def test_get_project_assets_no_path( + mock_connection: MagicMock, +) -> None: + """Test get_project_assets without project path.""" + result = await get_project_assets(mock_connection) + + assert result["success"] is False + assert result["code"] == "NO_PROJECT_PATH" + + +@pytest.mark.asyncio +async def test_scan_cpp_classes( + mock_connection_with_project: MagicMock, +) -> None: + """Test scan_cpp_classes.""" + result = await scan_cpp_classes(mock_connection_with_project) + + assert result["success"] is True + assert "data" in result + classes = result["data"] + assert len(classes) >= 1 + + # Find our test actor + test_actor = next((c for c in classes if c["name"] == "ATestActor"), None) + assert test_actor is not None + assert test_actor["parent"] == "AActor" + assert test_actor["is_blueprintable"] is True + + +@pytest.mark.asyncio +async def test_scan_cpp_classes_filter_blueprintable( + mock_connection_with_project: MagicMock, +) -> None: + """Test scan_cpp_classes with blueprintable filter.""" + result = await scan_cpp_classes( + mock_connection_with_project, + filter_blueprintable=True, + ) + + assert result["success"] is True + # All returned classes should be blueprintable + for cls in result["data"]: + assert cls["is_blueprintable"] is True