chore: initial project state (godot-mcp addon, docs)

This commit is contained in:
2026-04-13 04:34:39 +02:00
commit c4d29486c4
67 changed files with 6185 additions and 0 deletions

39
docs/STATUS.md Normal file
View File

@@ -0,0 +1,39 @@
# Ruf der Pilze — Projektstatus
Zuletzt aktualisiert: 2026-04-13
---
## Aktueller Stand
### ✅ Abgeschlossen
- MCP-Addon eingerichtet (`godot-mcp`, Claude Code verbunden)
- Projektstruktur angelegt (`ruf-der-pilze/`)
- CLAUDE.md mit vollständiger Spielkonzept-Dokumentation
### 🔄 In Arbeit
— (nichts aktiv)
### ⏳ Als nächstes
- **Multiplayer Grundgerüst** → Plan: `docs/plans/multiplayer-grundgeruest.md`
- Server-Szene (headless-fähig)
- Clients verbinden
- Basis-RPC testen
---
## Entwicklungs-Reihenfolge (gesamt)
1. ✅ MCP eingerichtet
2. ⏳ Multiplayer Grundgerüst (Server, Clients verbinden, rpc testen)
3. ⏳ Refektorium — asymmetrische Wahrnehmung (erster Raum)
4. ⏳ DM Regiepult Basics — Overlay-Toggle
5. ⏳ Alle Räume aufbauen
6. ⏳ Polish — Audio, Nebel, Licht, Würfel-UI
---
## Offene Entscheidungen
- Transport: ENet oder WebSocket? (WebSocket = Browser-kompatibel, ENet = performanter)
- VPS bereits vorhanden oder noch einzurichten?

0
docs/plans/.gitkeep Normal file
View File

View File

@@ -0,0 +1,184 @@
# Multiplayer Grundgerüst — Design Spec
**Datum:** 2026-04-13
**Status:** Abgenommen
**Projekt:** Ruf der Pilze (`ruf-der-pilze/`)
---
## Kontext
Das Spiel benötigt eine Multiplayer-Basis bevor irgendeine Spiellogik gebaut werden kann. Dieses Grundgerüst schafft die Verbindungsschicht: Server startet, Clients verbinden, RPCs funktionieren. Alles Weitere (Räume, Overlays, DM-Regiepult) baut darauf auf.
---
## Entscheidungen
| Entscheidung | Wahl | Begründung |
|---|---|---|
| Transport | ENet | Native Godot UDP, geringste Latenz, kein Browser-Support benötigt |
| Dev-Server | Lokal headless | Kein VPS-Overhead während Entwicklung |
| Max. Verbindungen | 8 | 5 Spieler + 1 DM + 2 Reserve |
| Architektur | Eine Hauptszene + Autoload | Godot-idiomatisch |
| Port | 4242 | Projektstandard |
| Server-Erkennung | `--server` CLI-Argument | `OS.has_feature("dedicated_server")` gilt nur für Server-Exports, nicht für headless Dev-Runs |
---
## Dateistruktur
```
ruf-der-pilze/
├── scenes/
│ └── main.tscn ← Root-Szene (Node)
├── scripts/
│ ├── main.gd ← Modus-Erkennung + Bootstrap
│ └── network_manager.gd ← Autoload: Peer-Verwaltung, Signale
└── project.godot ← NetworkManager als Autoload registriert
```
---
## Architektur
### NetworkManager (Autoload)
Singleton, der den gesamten Multiplayer-Peer hält. Szenen greifen über `NetworkManager` auf Verbindungsstatus und peer_ids zu.
**Verantwortlichkeiten:**
- ENetMultiplayerPeer erstellen (Server oder Client)
- Signale weiterleiten: `peer_connected(id)`, `peer_disconnected(id)`, `connected_to_server()`, `connection_failed()`
- Eigene peer_id bereitstellen: `NetworkManager.my_id` (wird bei Verbindung gesetzt)
- Liste aller verbundenen peers: `NetworkManager.peers` (Dictionary `id → {}`)
**Signale:**
```gdscript
signal peer_connected(id: int)
signal peer_disconnected(id: int)
signal connection_failed()
signal connected_to_server()
```
### main.gd — Modus-Erkennung
```gdscript
func _ready() -> void:
# OS.has_feature("dedicated_server") gilt nur bei Server-Export-Template.
# Für lokale Dev-Runs: --server als CLI-Argument übergeben.
var is_server := OS.has_feature("dedicated_server") \
or "--server" in OS.get_cmdline_user_args()
if is_server:
NetworkManager.start_server(4242, 8)
else:
NetworkManager.join_server("127.0.0.1", 4242)
```
### Server-Initialisierung
```gdscript
func start_server(port: int, max_clients: int) -> void:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
if err != OK:
push_error("[Server] Port %d konnte nicht gebunden werden: %s" % [port, error_string(err)])
return
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
print("[Server] Gestartet auf Port %d" % port)
func _on_peer_connected(id: int) -> void:
peers[id] = {}
peer_connected.emit(id)
print("[Server] Peer verbunden: %d" % id)
welcome.rpc_id(id, id) # nur an diesen Client
func _on_peer_disconnected(id: int) -> void:
peers.erase(id)
peer_disconnected.emit(id)
print("[Server] Peer getrennt: %d" % id)
```
### Client-Verbindung
```gdscript
func join_server(ip: String, port: int) -> void:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(ip, port)
if err != OK:
push_error("[Client] Verbindung zu %s:%d fehlgeschlagen: %s" % [ip, port, error_string(err)])
return
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
func _on_connected_to_server() -> void:
my_id = multiplayer.get_unique_id()
connected_to_server.emit()
print("[Client] Verbunden. Meine peer_id: %d" % my_id)
func _on_connection_failed() -> void:
connection_failed.emit()
push_error("[Client] Verbindung fehlgeschlagen")
```
### RPC-Smoke-Test
Server schickt nach jeder neuen Verbindung ein `welcome`-RPC **nur an diesen Client**:
```gdscript
# Hinweis: call_remote bedeutet der Server führt diese Funktion NICHT lokal aus.
# Sie wird ausschließlich auf dem Ziel-Client ausgeführt.
@rpc("authority", "call_remote", "reliable")
func welcome(peer_id: int) -> void:
print("[Client] Willkommen, meine peer_id ist: %d" % peer_id)
```
Erwartetes Verhalten:
- **Server-Terminal:** zeigt nur `[Server] Peer verbunden: 123456`
- **Client-Output:** zeigt `[Client] Verbunden. Meine peer_id: 123456` und `[Client] Willkommen, meine peer_id ist: 123456`
---
## Verifikation
### Schritt 1 — Server starten (headless)
```bash
cd ruf-der-pilze/
godot --headless -- --server
# Erwartete Ausgabe: [Server] Gestartet auf Port 4242
```
> **Hinweis:** `--` trennt Godot-Argumente von User-Argumenten. `OS.get_cmdline_user_args()` gibt `["--server"]` zurück.
### Schritt 2 — Client verbinden (Godot Editor, F5)
```
# Server-Terminal zeigt:
[Server] Peer verbunden: 123456
# Client-Ausgabe (Godot Output-Panel):
[Client] Verbunden. Meine peer_id: 123456
[Client] Willkommen, meine peer_id ist: 123456
```
### Schritt 3 — Zweiten Client testen
Zweiten Godot-Prozess starten → Server zeigt beide peer_ids, beide Clients erhalten ihre individuelle Welcome-Nachricht mit korrekter peer_id.
### Schritt 4 — Disconnect testen
Client schließen → Server-Terminal zeigt `[Server] Peer getrennt: 123456`, `peers` Dictionary hat den Eintrag entfernt.
---
## Was dieses Grundgerüst NICHT enthält
- Spieler-Rollen (Spieler vs. DM) — kommt im nächsten Schritt
- Authentifizierung / Lobby-Logik
- Reconnect-Handling
- Irgendwelche Spiellogik
---
## Nächster Schritt
Aufbauend auf diesem Grundgerüst: **Lobby + Rollen** (Spieler registrieren sich mit Name und Rolle, DM bekommt Sonderrechte).

View File

@@ -0,0 +1,338 @@
# Multiplayer Grundgerüst — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Server und Client verbinden sich via ENet, der Server schickt bei Verbindung ein welcome-RPC an den Client — verifiziert durch Terminal-Output.
**Architecture:** Eine einzige `main.tscn` erkennt per `OS.has_feature("dedicated_server") or "--server" in OS.get_cmdline_user_args()` ob sie Server oder Client ist und delegiert an den `NetworkManager`-Autoload. Der `NetworkManager` hält den ENet-Peer und leitet alle Verbindungs-Signale weiter.
**Tech Stack:** Godot 4.6.2, GDScript, ENetMultiplayerPeer, Godot MultiplayerAPI
**Spec:** `docs/plans/2026-04-13-multiplayer-grundgeruest-design.md`
**Arbeitsverzeichnis:** Alle Befehle werden aus `ruf-der-pilze/` ausgeführt, sofern nicht anders angegeben.
---
## Dateiübersicht
| Datei | Aktion | Verantwortlichkeit |
|---|---|---|
| `scripts/network_manager.gd` | Erstellen | Autoload: ENet-Peer, Signale, peer_id-Verwaltung |
| `scripts/main.gd` | Erstellen | Modus-Erkennung (Server/Client), Bootstrap |
| `scenes/main.tscn` | Erstellen | Root-Szene (Node + main.gd) |
| `project.godot` | Modifizieren | NetworkManager als Autoload, main.tscn als Hauptszene |
---
### Task 0: Voraussetzungen
**Files:** keine
- [ ] **Step 1: Godot-Binary prüfen**
```bash
which godot || which godot4
```
Merke dir den Namen (wahrscheinlich `godot4` auf Arch Linux). Ersetze `godot` in allen folgenden Schritten durch den tatsächlichen Binary-Namen.
- [ ] **Step 2: Verzeichnisse anlegen**
```bash
# aus ruf-der-pilze/
mkdir -p scripts scenes
```
- [ ] **Step 3: Git initialisieren (falls noch nicht vorhanden)**
Das Git-Repository soll den gesamten `DnD_Anna_OneShot/`-Ordner abdecken, damit `docs/` und `ruf-der-pilze/` gemeinsam versioniert werden.
```bash
# aus DnD_Anna_OneShot/ (eine Ebene höher als ruf-der-pilze/)
cd /home/mpuchstein/Dev/Godot/DnD_Anna_OneShot
git status 2>/dev/null || git init
```
Falls `git init` ausgeführt wurde: einmalig committen was schon da ist:
```bash
git add .
git commit -m "chore: initial project state (godot-mcp addon, docs)"
```
Danach für alle weiteren Schritte wieder in `ruf-der-pilze/` wechseln:
```bash
cd ruf-der-pilze/
```
---
### Task 1: NetworkManager-Autoload schreiben
**Files:**
- Create: `scripts/network_manager.gd`
- [ ] **Step 1: Datei erstellen**
Erstelle `ruf-der-pilze/scripts/network_manager.gd`:
```gdscript
extends Node
signal peer_connected(id: int)
signal peer_disconnected(id: int)
signal connected_to_server()
signal connection_failed()
var peers: Dictionary = {}
var my_id: int = 0
func start_server(port: int, max_clients: int) -> void:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_server(port, max_clients)
if err != OK:
push_error("[Server] Port %d konnte nicht gebunden werden: %s" % [port, error_string(err)])
return
multiplayer.multiplayer_peer = peer
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
print("[Server] Gestartet auf Port %d" % port)
func join_server(ip: String, port: int) -> void:
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(ip, port)
if err != OK:
push_error("[Client] Verbindung zu %s:%d fehlgeschlagen: %s" % [ip, port, error_string(err)])
return
multiplayer.multiplayer_peer = peer
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
func _on_peer_connected(id: int) -> void:
peers[id] = {}
peer_connected.emit(id)
print("[Server] Peer verbunden: %d" % id)
welcome.rpc_id(id, id)
func _on_peer_disconnected(id: int) -> void:
peers.erase(id)
peer_disconnected.emit(id)
print("[Server] Peer getrennt: %d" % id)
func _on_connected_to_server() -> void:
my_id = multiplayer.get_unique_id()
connected_to_server.emit()
print("[Client] Verbunden. Meine peer_id: %d" % my_id)
func _on_connection_failed() -> void:
connection_failed.emit()
push_error("[Client] Verbindung fehlgeschlagen")
@rpc("authority", "call_remote", "reliable")
func welcome(peer_id: int) -> void:
# call_remote: wird NUR auf dem Ziel-Client ausgeführt, nicht auf dem Server.
print("[Client] Willkommen, meine peer_id ist: %d" % peer_id)
```
- [ ] **Step 2: Syntax im Editor prüfen**
`scripts/network_manager.gd` in Godot öffnen → Script-Editor zeigt keine roten Fehler-Markierungen. Kein Parse-Error im Output-Panel.
- [ ] **Step 3: Commit**
```bash
# aus ruf-der-pilze/
git add scripts/network_manager.gd
git commit -m "net: add NetworkManager autoload with ENet server/client and welcome RPC"
```
---
### Task 2: Autoload in project.godot registrieren
**Files:**
- Modify: `project.godot`
- [ ] **Step 1: Autoload über Editor registrieren**
In Godot: **Project → Project Settings → Autoload**
- Pfad: `res://scripts/network_manager.gd`
- Name: `NetworkManager`
- Global Variable: ✅ aktiviert
- Auf "Add" klicken → "NetworkManager" erscheint in der Liste
`project.godot` enthält danach:
```ini
[autoload]
NetworkManager="*res://scripts/network_manager.gd"
```
- [ ] **Step 2: Verifizieren**
Godot-Editor schließen und neu starten (Projekt erneut öffnen). Prüfen ob im Output-Panel beim Start Fehler erscheinen. Kein "Could not load script" oder "NetworkManager not found" → Autoload korrekt registriert.
- [ ] **Step 3: Commit**
```bash
# aus ruf-der-pilze/
git add project.godot
git commit -m "net: register NetworkManager as autoload"
```
---
### Task 3: main.gd schreiben
**Files:**
- Create: `scripts/main.gd`
- [ ] **Step 1: Datei erstellen**
Erstelle `ruf-der-pilze/scripts/main.gd`:
```gdscript
extends Node
func _ready() -> void:
# OS.has_feature("dedicated_server") gilt nur bei Server-Export-Template.
# Für lokale Dev-Runs: --server als User-Argument übergeben (nach -- in CLI).
var is_server := OS.has_feature("dedicated_server") \
or "--server" in OS.get_cmdline_user_args()
if is_server:
NetworkManager.start_server(4242, 8)
else:
NetworkManager.join_server("127.0.0.1", 4242)
```
- [ ] **Step 2: Syntax prüfen**
Script im Godot-Editor öffnen → keine Fehler.
- [ ] **Step 3: Commit**
```bash
# aus ruf-der-pilze/
git add scripts/main.gd
git commit -m "net: add main.gd with server/client mode detection"
```
---
### Task 4: main.tscn erstellen und als Hauptszene setzen
**Files:**
- Create: `scenes/main.tscn`
- Modify: `project.godot`
- [ ] **Step 1: Szene erstellen**
In Godot: **Scene → New Scene**
- Root-Node: `Node` (nicht Node2D oder Node3D)
- Node umbenennen zu: `Main`
- Script anhängen: über den Inspector → Script-Slot → `res://scripts/main.gd` wählen
- Speichern als: `res://scenes/main.tscn` (Ctrl+S)
- [ ] **Step 2: Als Hauptszene setzen**
**Project → Project Settings → Application → Run → Main Scene**`res://scenes/main.tscn` auswählen
- [ ] **Step 3: Commit**
```bash
# aus ruf-der-pilze/
git add scenes/main.tscn project.godot
git commit -m "net: add main.tscn as root scene"
```
---
### Task 5: Integration testen — Server + Client
**Files:** keine Änderungen
- [ ] **Step 1: Server headless starten**
```bash
# aus ruf-der-pilze/ — Binary-Name ggf. anpassen (siehe Task 0)
godot --headless -- --server
```
Erwartete Ausgabe:
```
[Server] Gestartet auf Port 4242
```
Falls kein Output: `OS.get_cmdline_user_args()` prüfen — das `--` ist zwingend (trennt Godot-Args von User-Args). Ohne `--` landet `--server` in `get_cmdline_args()`, nicht in `get_cmdline_user_args()`.
- [ ] **Step 2: Client verbinden (Godot Editor)**
Godot-Editor → F5 (Projekt ausführen, kein `--server` Argument)
*Server-Terminal:*
```
[Server] Peer verbunden: 123456
```
*Godot Output-Panel (Client):*
```
[Client] Verbunden. Meine peer_id: 123456
[Client] Willkommen, meine peer_id ist: 123456
```
Hinweis: Der Server printet `welcome` **nicht**`call_remote` bedeutet die Funktion läuft nur auf dem Ziel-Client.
- [ ] **Step 3: Disconnect testen**
Client (Editor) schließen / F8 → Server-Terminal zeigt:
```
[Server] Peer getrennt: 123456
```
- [ ] **Step 4: STATUS.md updaten**
`/home/mpuchstein/Dev/Godot/DnD_Anna_OneShot/docs/STATUS.md` öffnen und aktualisieren:
```markdown
### ✅ Abgeschlossen
- MCP-Addon eingerichtet
- Projektstruktur angelegt
- CLAUDE.md mit vollständiger Spielkonzept-Dokumentation
- **Multiplayer Grundgerüst** — ENet Server/Client, NetworkManager Autoload, welcome RPC verifiziert
### ⏳ Als nächstes
- Lobby + Rollen (Spieler registrieren sich mit Name + Rolle, DM kriegt Sonderrechte)
Plan: noch zu erstellen
```
- [ ] **Step 5: Finaler Commit**
```bash
# aus DnD_Anna_OneShot/ (Repo-Root, weil docs/ dort liegt)
cd /home/mpuchstein/Dev/Godot/DnD_Anna_OneShot
git add docs/STATUS.md
git commit -m "docs: mark Multiplayer Grundgerüst as complete in STATUS.md"
```
---
## Verifikations-Checkliste
Vor "fertig" erklären:
- [ ] `[Server] Gestartet auf Port 4242` erscheint beim headless Start
- [ ] `[Server] Peer verbunden: <id>` erscheint wenn Client verbindet
- [ ] `[Client] Verbunden. Meine peer_id: <id>` erscheint im Client
- [ ] `[Client] Willkommen, meine peer_id ist: <id>` erscheint im Client (RPC angekommen)
- [ ] `[Server] Peer getrennt: <id>` erscheint wenn Client schließt
- [ ] Kein `push_error` oder Parser-Fehler in keiner Ansicht

View File

@@ -0,0 +1,4 @@
root = true
[*]
charset = utf-8

2
ruf-der-pilze/.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf

3
ruf-der-pilze/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
# Godot 4+ specific ignores
.godot/
/android/

220
ruf-der-pilze/CLAUDE.md Normal file
View File

@@ -0,0 +1,220 @@
# Der Ruf der Pilze — Projekt-Kontext für Claude Code
## Was wir bauen
Ein atmosphärischer Multiplayer One-Shot für D&D 5e, gespielt über Godot 4.
Kein klassisches Spiel — eher eine interaktive Bühne für eine Gruppe von 35 Spielern + 1 DM.
**Kernmechanik:** Sporen-induzierte asymmetrische Wahrnehmung. Jeder Spieler sieht eine
andere Realität. Die Rätsel sind nur durch Kommunikation zwischen den Spielern lösbar.
Das Spiel verrät den Spielern nie direkt, dass sie verschiedenes sehen — sie entdecken
es selbst im Gespräch.
---
## Tech Stack
- **Engine:** Godot 4.x (GDScript)
- **Multiplayer:** Godot native MultiplayerAPI (ENet/WebSocket), kein Mirror
- **MCP:** @satelliteoflove/godot-mcp (Screenshot-Feedback-Loop mit Claude Code)
- **Hosting:** VPS, Godot Dedicated Server (headless)
- **Plattform:** Arch Linux
---
## Architektur
```
VPS — Godot Dedicated Server (headless)
├── GameState — welcher Spieler welche Realität sieht (Dictionary peer_id → state)
├── RoomManager — Türen, Trigger, Rätsel-States pro Raum
└── SporeLevel — Sporennebel-Dichte pro Raum (0.0 1.0)
Spieler-Client (Ego-Perspektive, First Person)
├── PlayerController — Bewegung, Interaktion
├── OverlayManager — empfängt per rpc_id() nur seinen eigenen Overlay-State
├── AudioManager — direktionaler Audio lokal berechnet
└── DiceRoller — d20 + Modifier, Ergebnis wird an alle gebroadcastet
DM-Client (Top-Down-Ansicht)
└── RegiepultUI
├── Spieler-Positionen live
├── Overlay-Toggle pro Spieler
├── Sporennebel-Slider pro Raum
├── Türen/Geometrie manipulieren
├── Audio-Trigger (Annas Spieluhr-Lied, direktional)
└── DC-Anzeige bei Würfelwürfen
```
**Asymmetrische Wahrnehmung** läuft über `rpc_id()` — der Server schickt jedem
Spieler nur seinen eigenen Overlay-State:
```gdscript
# Nur an einen bestimmten Spieler schicken
rpc_id(peer_id, "show_overlay", "living_monastery")
```
---
## Story
Die Spielergruppe verbringt einen Abend in einer Taverne. Am nächsten Morgen
ist **Anna** verschwunden — Robe halb zersetzt auf dem Boden, eine Myzelspur
führt aus dem Zimmer in den Wald.
Das Kloster am Waldrand trägt das Symbol von Annas Orden. Die Spieler erkunden
es, erleben Sporen-induzierte Halluzinationen, sehen verschiedene Realitäten
und müssen zusammenarbeiten um zu Anna zu gelangen.
Am Ende finden sie Anna — lächelnd, verbunden mit dem Myzel, vollständig ruhig.
> *"Ich bin nicht verloren. Immer gefunden. Ich bin ja schon groß."*
Alle wachen in der Taverne auf. Anna sitzt beim Frühstück. Lächelt. Sagt nichts.
Die Spieler schauen auf ihre eigenen Hände — Dreck unter den Fingernägeln.
War es ein Traum? Hat **Putrescor** sie kurz ins Netzwerk gelassen?
**Offen. Für immer.**
### Putrescor
Annas Patron (Spore Druid / Great Old One Warlock). Ein Myzel-Bewusstsein das
alles verbindet was je gewachsen und verrottet ist. Kommuniziert nicht in Worten
— in Verbindung. Das Kloster ist kein Ort, es ist eine Erinnerung im Netzwerk.
---
## Raumstruktur
```
[START] Taverne → Myzelpfad → Klostertor
┌─────────▼─────────┐
│ EINGANGSHALLE │ ← Atmosphäre, kein Rätsel
└─────────┬─────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
REFEKTORIUM BIBLIOTHEK KAPELLE
"Zwei Welten" "Flüsternetz" "Spiegelbild"
└───────────────┼───────────────┘
┌─────────────────┐
│ KREUZGANG │ ← Optionaler Wächter
└────────┬────────┘
┌─────────────────┐
│ SANCTUM │ ← "Annas Lied"
└────────┬────────┘
ANNA
```
Die drei mittleren Räume (Refektorium, Bibliothek, Kapelle) können in
**beliebiger Reihenfolge** gespielt werden. Erst wenn alle drei gelöst sind,
öffnet sich der Kreuzgang.
---
## Rätsel im Detail
### 🍽️ Refektorium — "Zwei Welten, ein Raum"
- **2 Spieler** sehen lebendiges Kloster: Mönche, Kerzen, Gesang
- **23 Spieler** sehen Ruinen: Skelette, Schimmel, Mondlicht
- Ein Mönch (nur Gruppe A sichtbar) klopft an einen Stein in der Wand
- Gruppe B sieht nur verwitterten Stein — aber der Stein ist hohl
- Dahinter: **Schlüssel Nr. 1**
- DM-Toggle: welche Spieler welche Realität sehen
- Checks: Investigation DC 12 (Gruppe B), Perception DC 10 (alle)
### 📚 Bibliothek — "Das Flüsternetz"
- Myzelfasern in den Regalen flüstern — jeder Spieler hört ein anderes Fragment
- Spieler A: *"...wenn der erste Mond..."*
- Spieler B: *"...das Feuer erlischt..."*
- Spieler C: *"...sprich ihren Namen..."*
- Spieler D: *"...und der Kreis sich schließt"*
- Zusammen: *"Wenn der erste Mond das Feuer erlischt, sprich ihren Namen und der Kreis sich schließt."*
- Kerzenkreis im Raum: alle Kerzen löschen + "Anna" sagen → Geheimfach → **Schlüssel Nr. 2**
- Bei hoher Sporendichte: Fragmente leicht verzerren (ein Spieler hört "deinen" statt "ihren")
- Checks: Perception DC 13 (verzerrt), Arcana DC 11 (Ritual)
### ⛪ Kapelle — "Das ehrliche Spiegelbild"
- Großer Spiegel am Altar, jeder sieht etwas anderes:
- Spieler A: eigenes Gesicht, Augen geschlossen
- Spieler B: den Raum ohne sich selbst
- Spieler C: Anna, die winkt
- Spieler D: Spiegel mit Riss — dahinter Licht
- Nur einer sieht die Wahrheit: hinter dem Spiegel ist ein Hebel → **Schlüssel Nr. 3**
- Falsche Aktion: 3-Sekunden-Halluzination für alle — sie sehen Anna, die lächelt
- **Kein mechanischer Hinweis** wer die Wahrheit sieht — reines Rollenspiel
- Checks: Insight DC 14, Investigation DC 12
### 🌿 Kreuzgang — Optionaler Wächter
- Alle drei Lösungen an drei Symbolen an der Wand einsetzen
- Wächter (Mönch aus Pilzgeflecht) erscheint wenn Gruppe zu laut/hastig war
- Checks: Nature DC 13 (Vorteil für Druid/Ranger), Persuasion DC 15, Stealth DC 12 (alle)
- **Kein Kampf** — nur Skill Checks
### 🌑 Sanctum — "Annas Lied"
- Dunkel, nur Biolumineszenz der Pilze
- Spieluhr-Melodie klingt für jeden aus anderer Richtung
- Wenn alle gleichzeitig in ihre Richtung schauen: bilden sie einen Kreis
- In der Mitte: Anna
- **Kein Check** — reines Erzählen
---
## Sporennebel
Steigt mit jeder Ebene. DM-steuerbar per Slider im Regiepult.
| Raum | Dichte | Effekt |
|------|--------|--------|
| Eingangshalle | Gering | Atmosphäre, keine Checks |
| Refektorium / Bibliothek / Kapelle | Mittel | Checks können verzerrt sein |
| Kreuzgang | Hoch | Stärkere Overlays möglich |
| Sanctum | Maximum | Reine Erzählung |
---
## Würfel
Nur ein einfacher d20-Roller nötig:
```
[ 🎲 d20 ] Modifier: [+3] → Ergebnis: 17
```
- Wurf sichtbar für alle Spieler + DM
- DM sieht DC im Regiepult (Spieler nicht)
- Kein Kampfsystem, kein Initiative-Tracker, kein vollständiges Charsheet ingame
---
## Anna (Spieler-Charakter, wird vom DM gespielt)
- **Klasse:** Circle of Spores Druid 3 / Great Old One Warlock 2
- **Patron:** Putrescor (das Myzel-Netzwerk selbst)
- **Erscheinung:** zerfetzte Ordensroben durchwoben mit Myzel, gräuliche Haut,
Pilze die auf ihr wachsen, Myzelfäden aus den Fingern, Augen etwas zu groß,
Lächeln etwas zu lang gehalten
- **Persönlichkeit:** enthusiastisch fröhlich, distanzierte Neugier gegenüber
Leid und Verfall, kein Böswille — alles ist natürlicher Prozess
- Im One-Shot: NPC, taucht erst im Sanctum auf
---
## Entwicklungs-Reihenfolge
1. ✅ MCP eingerichtet (@satelliteoflove/godot-mcp, Claude Code verbunden)
2. Multiplayer Grundgerüst (Server, Clients verbinden, rpc testen)
3. Erster Raum — Refektorium mit asymmetrischer Wahrnehmung
4. DM Regiepult Basics — Overlay-Toggle
5. Alle Räume aufbauen
6. Polish — Audio, Nebel, Licht, Würfel-UI
---
## Wichtige Designprinzipien
- Das System sagt den Spielern **nie** dass sie verschiedenes sehen
- Die soziale Kommunikationsschicht **ist** die Mechanik
- Das Ende (Dreck unter den Fingernägeln) bleibt **für immer offen**
- Kein Kampf — die Atmosphäre ist das Spiel
- DM hat volle Kontrolle über alle Wahrnehmungen in Echtzeit

View File

@@ -0,0 +1,40 @@
@tool
extends RefCounted
class_name MCPCommandRouter
var _commands: Dictionary = {}
var _handlers: Array[MCPBaseCommand] = []
func setup(plugin: EditorPlugin) -> void:
_register_handler(MCPSystemCommands.new(), plugin)
_register_handler(MCPSceneCommands.new(), plugin)
_register_handler(MCPNodeCommands.new(), plugin)
_register_handler(MCPScriptCommands.new(), plugin)
_register_handler(MCPSelectionCommands.new(), plugin)
_register_handler(MCPProjectCommands.new(), plugin)
_register_handler(MCPDebugCommands.new(), plugin)
_register_handler(MCPScreenshotCommands.new(), plugin)
_register_handler(MCPAnimationCommands.new(), plugin)
_register_handler(MCPTilemapCommands.new(), plugin)
_register_handler(MCPResourceCommands.new(), plugin)
_register_handler(MCPScene3DCommands.new(), plugin)
_register_handler(MCPInputCommands.new(), plugin)
_register_handler(MCPProfilerCommands.new(), plugin)
func _register_handler(handler: MCPBaseCommand, plugin: EditorPlugin) -> void:
handler.setup(plugin)
_handlers.append(handler)
var cmds := handler.get_commands()
for cmd_name in cmds:
_commands[cmd_name] = cmds[cmd_name]
func handle_command(command: String, params: Dictionary):
if not _commands.has(command):
return MCPUtils.error("UNKNOWN_COMMAND", "Unknown command: %s" % command)
var callable: Callable = _commands[command]
var result = await callable.call(params)
return result

View File

@@ -0,0 +1 @@
uid://dx5jdjvtk6qce

View File

@@ -0,0 +1,633 @@
@tool
extends MCPBaseCommand
class_name MCPAnimationCommands
const TRACK_TYPE_MAP := {
"value": Animation.TYPE_VALUE,
"position_3d": Animation.TYPE_POSITION_3D,
"rotation_3d": Animation.TYPE_ROTATION_3D,
"scale_3d": Animation.TYPE_SCALE_3D,
"blend_shape": Animation.TYPE_BLEND_SHAPE,
"method": Animation.TYPE_METHOD,
"bezier": Animation.TYPE_BEZIER,
"audio": Animation.TYPE_AUDIO,
"animation": Animation.TYPE_ANIMATION
}
const LOOP_MODE_MAP := {
"none": Animation.LOOP_NONE,
"linear": Animation.LOOP_LINEAR,
"pingpong": Animation.LOOP_PINGPONG
}
func get_commands() -> Dictionary:
return {
"list_animation_players": list_animation_players,
"get_animation_player_info": get_animation_player_info,
"get_animation_details": get_animation_details,
"get_track_keyframes": get_track_keyframes,
"play_animation": play_animation,
"stop_animation": stop_animation,
"seek_animation": seek_animation,
"create_animation": create_animation,
"delete_animation": delete_animation,
"update_animation_properties": update_animation_properties,
"add_animation_track": add_animation_track,
"remove_animation_track": remove_animation_track,
"add_keyframe": add_keyframe,
"remove_keyframe": remove_keyframe,
"update_keyframe": update_keyframe
}
func _get_animation_player(node_path: String) -> AnimationPlayer:
var node := _get_node(node_path)
if not node:
return null
if not node is AnimationPlayer:
return null
return node as AnimationPlayer
func _get_animation(player: AnimationPlayer, anim_name: String) -> Animation:
if not player.has_animation(anim_name):
return null
return player.get_animation(anim_name)
func _track_type_to_string(track_type: int) -> String:
for key in TRACK_TYPE_MAP:
if TRACK_TYPE_MAP[key] == track_type:
return key
return "unknown"
func _loop_mode_to_string(loop_mode: int) -> String:
for key in LOOP_MODE_MAP:
if LOOP_MODE_MAP[key] == loop_mode:
return key
return "none"
func _find_animation_players(node: Node, result: Array, root: Node) -> void:
if node is AnimationPlayer:
var relative_path := str(root.get_path_to(node))
result.append({
"path": relative_path,
"name": node.name
})
for child in node.get_children():
_find_animation_players(child, result, root)
func list_animation_players(params: Dictionary) -> Dictionary:
var root_path: String = params.get("root_path", "")
var root: Node
if root_path.is_empty():
root = EditorInterface.get_edited_scene_root()
else:
root = _get_node(root_path)
if not root:
return _error("NODE_NOT_FOUND", "Root node not found")
var players := []
_find_animation_players(root, players, root)
return _success({"animation_players": players})
func get_animation_player_info(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer: %s" % node_path)
var libraries := {}
for lib_name in player.get_animation_library_list():
var lib := player.get_animation_library(lib_name)
libraries[lib_name] = Array(lib.get_animation_list())
return _success({
"current_animation": player.current_animation,
"is_playing": player.is_playing(),
"current_position": player.current_animation_position,
"speed_scale": player.speed_scale,
"libraries": libraries,
"animation_count": player.get_animation_list().size()
})
func get_animation_details(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
var tracks := []
for i in range(anim.get_track_count()):
tracks.append({
"index": i,
"type": _track_type_to_string(anim.track_get_type(i)),
"path": str(anim.track_get_path(i)),
"interpolation": anim.track_get_interpolation_type(i),
"keyframe_count": anim.track_get_key_count(i)
})
var lib_name := ""
var pure_name := anim_name
if "/" in anim_name:
var parts := anim_name.split("/", true, 1)
lib_name = parts[0]
pure_name = parts[1]
return _success({
"name": pure_name,
"library": lib_name,
"length": anim.length,
"loop_mode": _loop_mode_to_string(anim.loop_mode),
"step": anim.step,
"track_count": anim.get_track_count(),
"tracks": tracks
})
func get_track_keyframes(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var track_index: int = params.get("track_index", -1)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
if track_index < 0:
return _error("INVALID_PARAMS", "track_index is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
if track_index >= anim.get_track_count():
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
var keyframes := []
var track_type := anim.track_get_type(track_index)
for i in range(anim.track_get_key_count(track_index)):
var kf := {
"time": anim.track_get_key_time(track_index, i),
"transition": anim.track_get_key_transition(track_index, i)
}
match track_type:
Animation.TYPE_METHOD:
kf["method"] = anim.method_track_get_name(track_index, i)
kf["args"] = anim.method_track_get_params(track_index, i)
Animation.TYPE_BEZIER:
kf["value"] = anim.bezier_track_get_key_value(track_index, i)
kf["in_handle"] = _serialize_value(anim.bezier_track_get_key_in_handle(track_index, i))
kf["out_handle"] = _serialize_value(anim.bezier_track_get_key_out_handle(track_index, i))
_:
kf["value"] = _serialize_value(anim.track_get_key_value(track_index, i))
keyframes.append(kf)
return _success({
"track_path": str(anim.track_get_path(track_index)),
"track_type": _track_type_to_string(track_type),
"keyframes": keyframes
})
func play_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var custom_blend: float = params.get("custom_blend", -1.0)
var custom_speed: float = params.get("custom_speed", 1.0)
var from_end: bool = params.get("from_end", false)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
if not player.has_animation(anim_name):
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
player.play(anim_name, custom_blend, custom_speed, from_end)
return _success({"playing": anim_name, "from_position": player.current_animation_position})
func stop_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var keep_state: bool = params.get("keep_state", false)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
player.stop(keep_state)
return _success({"stopped": true})
func seek_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var seconds: float = params.get("seconds", 0.0)
var update: bool = params.get("update", true)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("seconds"):
return _error("INVALID_PARAMS", "seconds is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
player.seek(seconds, update)
return _success({"position": player.current_animation_position})
func create_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var lib_name: String = params.get("library_name", "")
var length: float = params.get("length", 1.0)
var loop_mode: String = params.get("loop_mode", "none")
var step: float = params.get("step", 0.1)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var lib: AnimationLibrary
if player.has_animation_library(lib_name):
lib = player.get_animation_library(lib_name)
else:
lib = AnimationLibrary.new()
player.add_animation_library(lib_name, lib)
if lib.has_animation(anim_name):
return _error("ANIMATION_EXISTS", "Animation already exists: %s" % anim_name)
var anim := Animation.new()
anim.length = length
if LOOP_MODE_MAP.has(loop_mode):
anim.loop_mode = LOOP_MODE_MAP[loop_mode]
anim.step = step
var err := lib.add_animation(anim_name, anim)
if err != OK:
return _error("CREATE_FAILED", "Failed to create animation: %s" % error_string(err))
return _success({"created": anim_name, "library": lib_name})
func delete_animation(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var lib_name: String = params.get("library_name", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
if not player.has_animation_library(lib_name):
return _error("LIBRARY_NOT_FOUND", "Animation library not found: %s" % lib_name)
var lib := player.get_animation_library(lib_name)
if not lib.has_animation(anim_name):
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
lib.remove_animation(anim_name)
return _success({"deleted": anim_name})
func update_animation_properties(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
var updated := {}
if params.has("length"):
anim.length = params["length"]
updated["length"] = anim.length
if params.has("loop_mode"):
var loop_str: String = params["loop_mode"]
if LOOP_MODE_MAP.has(loop_str):
anim.loop_mode = LOOP_MODE_MAP[loop_str]
updated["loop_mode"] = loop_str
if params.has("step"):
anim.step = params["step"]
updated["step"] = anim.step
return _success({"updated": anim_name, "properties": updated})
func add_animation_track(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var track_type: String = params.get("track_type", "")
var track_path: String = params.get("track_path", "")
var insert_at: int = params.get("insert_at", -1)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
if track_type.is_empty():
return _error("INVALID_PARAMS", "track_type is required")
if track_path.is_empty():
return _error("INVALID_PARAMS", "track_path is required")
if not TRACK_TYPE_MAP.has(track_type):
return _error("INVALID_TRACK_TYPE", "Invalid track type: %s. Valid types: %s" % [track_type, ", ".join(TRACK_TYPE_MAP.keys())])
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
var godot_track_type: int = TRACK_TYPE_MAP[track_type]
var track_index: int
if insert_at >= 0:
track_index = anim.add_track(godot_track_type, insert_at)
else:
track_index = anim.add_track(godot_track_type)
anim.track_set_path(track_index, track_path)
return _success({
"track_index": track_index,
"track_path": track_path,
"track_type": track_type
})
func remove_animation_track(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var track_index: int = params.get("track_index", -1)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
if track_index < 0:
return _error("INVALID_PARAMS", "track_index is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
if track_index >= anim.get_track_count():
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
anim.remove_track(track_index)
return _success({"removed_track": track_index})
func add_keyframe(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var track_index: int = params.get("track_index", -1)
var time: float = params.get("time", 0.0)
var transition: float = params.get("transition", 1.0)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
if track_index < 0:
return _error("INVALID_PARAMS", "track_index is required")
if not params.has("time"):
return _error("INVALID_PARAMS", "time is required")
if not params.has("value"):
return _error("INVALID_PARAMS", "value is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
if track_index >= anim.get_track_count():
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
var value = MCPUtils.deserialize_value(params["value"])
var track_type := anim.track_get_type(track_index)
var key_index: int
match track_type:
Animation.TYPE_BEZIER:
key_index = anim.bezier_track_insert_key(track_index, time, value)
Animation.TYPE_METHOD:
var method_name: String = params.get("method_name", "")
var args: Array = params.get("args", [])
key_index = anim.method_track_add_key(track_index, time, method_name, args)
_:
key_index = anim.track_insert_key(track_index, time, value, transition)
return _success({
"keyframe_index": key_index,
"time": time,
"value": _serialize_value(value)
})
func remove_keyframe(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var track_index: int = params.get("track_index", -1)
var keyframe_index: int = params.get("keyframe_index", -1)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
if track_index < 0:
return _error("INVALID_PARAMS", "track_index is required")
if keyframe_index < 0:
return _error("INVALID_PARAMS", "keyframe_index is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
if track_index >= anim.get_track_count():
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
if keyframe_index >= anim.track_get_key_count(track_index):
return _error("KEYFRAME_NOT_FOUND", "Keyframe index out of range: %d" % keyframe_index)
anim.track_remove_key(track_index, keyframe_index)
return _success({"removed_keyframe": keyframe_index, "track_index": track_index})
func update_keyframe(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var anim_name: String = params.get("animation_name", "")
var track_index: int = params.get("track_index", -1)
var keyframe_index: int = params.get("keyframe_index", -1)
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if anim_name.is_empty():
return _error("INVALID_PARAMS", "animation_name is required")
if track_index < 0:
return _error("INVALID_PARAMS", "track_index is required")
if keyframe_index < 0:
return _error("INVALID_PARAMS", "keyframe_index is required")
var player := _get_animation_player(node_path)
if not player:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_ANIMATION_PLAYER", "Node is not an AnimationPlayer")
var anim := _get_animation(player, anim_name)
if not anim:
return _error("ANIMATION_NOT_FOUND", "Animation not found: %s" % anim_name)
if track_index >= anim.get_track_count():
return _error("TRACK_NOT_FOUND", "Track index out of range: %d" % track_index)
if keyframe_index >= anim.track_get_key_count(track_index):
return _error("KEYFRAME_NOT_FOUND", "Keyframe index out of range: %d" % keyframe_index)
var result := {}
if params.has("time"):
var new_time: float = params["time"]
var old_value = anim.track_get_key_value(track_index, keyframe_index)
var old_transition := anim.track_get_key_transition(track_index, keyframe_index)
anim.track_remove_key(track_index, keyframe_index)
keyframe_index = anim.track_insert_key(track_index, new_time, old_value, old_transition)
result["time"] = new_time
result["keyframe_index"] = keyframe_index
if params.has("value"):
var new_value = MCPUtils.deserialize_value(params["value"])
anim.track_set_key_value(track_index, keyframe_index, new_value)
result["value"] = _serialize_value(new_value)
if params.has("transition"):
var new_transition: float = params["transition"]
anim.track_set_key_transition(track_index, keyframe_index, new_transition)
result["transition"] = new_transition
return _success({"updated_keyframe": keyframe_index, "changes": result})

View File

@@ -0,0 +1 @@
uid://c3hb6slopq75j

View File

@@ -0,0 +1,134 @@
@tool
extends MCPBaseCommand
class_name MCPDebugCommands
const DEBUG_OUTPUT_TIMEOUT := 5.0
var _debug_output_result: PackedStringArray = []
var _debug_output_pending: bool = false
func get_commands() -> Dictionary:
return {
"run_project": run_project,
"stop_project": stop_project,
"get_debug_output": get_debug_output,
"get_log_messages": get_log_messages,
"get_errors": get_errors,
"get_stack_trace": get_stack_trace,
}
func run_project(params: Dictionary) -> Dictionary:
var scene_path: String = params.get("scene_path", "")
MCPLogger.clear()
if scene_path.is_empty():
EditorInterface.play_main_scene()
else:
EditorInterface.play_custom_scene(scene_path)
return _success({})
func stop_project(_params: Dictionary) -> Dictionary:
EditorInterface.stop_playing_scene()
return _success({})
func get_debug_output(params: Dictionary) -> Dictionary:
var clear: bool = params.get("clear", false)
var source: String = params.get("source", "")
if source == "editor":
var output := "\n".join(MCPLogger.get_output())
if clear:
MCPLogger.clear()
return _success({"output": output, "source": "editor"})
if source == "game":
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running. Use source: 'editor' for editor output.")
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session. Use source: 'editor' for editor output.")
return await _fetch_game_debug_output(debugger_plugin, clear)
if not EditorInterface.is_playing_scene():
var output := "\n".join(MCPLogger.get_output())
if clear:
MCPLogger.clear()
return _success({"output": output, "source": "editor"})
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
var output := "\n".join(MCPLogger.get_output())
if clear:
MCPLogger.clear()
return _success({"output": output, "source": "editor"})
return await _fetch_game_debug_output(debugger_plugin, clear)
func _fetch_game_debug_output(debugger_plugin: MCPDebuggerPlugin, clear: bool) -> Dictionary:
_debug_output_pending = true
_debug_output_result = PackedStringArray()
debugger_plugin.debug_output_received.connect(_on_debug_output_received, CONNECT_ONE_SHOT)
debugger_plugin.request_debug_output(clear)
var start_time := Time.get_ticks_msec()
while _debug_output_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > DEBUG_OUTPUT_TIMEOUT:
_debug_output_pending = false
if debugger_plugin.debug_output_received.is_connected(_on_debug_output_received):
debugger_plugin.debug_output_received.disconnect(_on_debug_output_received)
return _success({"output": "\n".join(MCPLogger.get_output()), "source": "editor"})
return _success({"output": "\n".join(_debug_output_result), "source": "game"})
func _on_debug_output_received(output: PackedStringArray) -> void:
_debug_output_pending = false
_debug_output_result = output
func get_log_messages(params: Dictionary) -> Dictionary:
var clear: bool = params.get("clear", false)
var limit: int = params.get("limit", 50)
var all_messages := MCPLogger.get_errors()
var total_count := all_messages.size()
var limited: Array[Dictionary] = []
var start_index := maxi(0, total_count - limit)
for i in range(start_index, total_count):
limited.append(all_messages[i])
if clear:
MCPLogger.clear_errors()
return _success({
"total_count": total_count,
"returned_count": limited.size(),
"messages": limited,
})
func get_errors(params: Dictionary) -> Dictionary:
return get_log_messages(params)
func get_stack_trace(_params: Dictionary) -> Dictionary:
var frames := MCPLogger.get_last_stack_trace()
var errors := MCPLogger.get_errors()
var last_error: Dictionary = errors[-1] if not errors.is_empty() else {}
return _success({
"error": last_error.get("message", ""),
"error_type": last_error.get("type", ""),
"file": last_error.get("file", ""),
"line": last_error.get("line", 0),
"frames": frames,
})

View File

@@ -0,0 +1 @@
uid://bfm205ak170xo

View File

@@ -0,0 +1,193 @@
@tool
extends MCPBaseCommand
class_name MCPInputCommands
const INPUT_TIMEOUT := 30.0
var _input_map_result: Dictionary = {}
var _input_map_pending: bool = false
var _sequence_result: Dictionary = {}
var _sequence_pending: bool = false
var _type_text_result: Dictionary = {}
var _type_text_pending: bool = false
func get_commands() -> Dictionary:
return {
"get_input_map": get_input_map,
"execute_input_sequence": execute_input_sequence,
"type_text": type_text,
}
func get_input_map(_params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return _get_editor_input_map()
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _get_editor_input_map()
_input_map_pending = true
_input_map_result = {}
debugger_plugin.input_map_received.connect(_on_input_map_received, CONNECT_ONE_SHOT)
debugger_plugin.request_input_map()
var start_time := Time.get_ticks_msec()
while _input_map_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > INPUT_TIMEOUT:
_input_map_pending = false
if debugger_plugin.input_map_received.is_connected(_on_input_map_received):
debugger_plugin.input_map_received.disconnect(_on_input_map_received)
return _get_editor_input_map()
return _success(_input_map_result)
func _get_editor_input_map() -> Dictionary:
var actions: Array[Dictionary] = []
for action_name in InputMap.get_actions():
if action_name.begins_with("ui_"):
continue
var events := InputMap.action_get_events(action_name)
var event_strings: Array[String] = []
for event in events:
event_strings.append(_event_to_string(event))
actions.append({
"name": action_name,
"events": event_strings,
})
return _success({"actions": actions, "source": "editor"})
func _event_to_string(event: InputEvent) -> String:
if event is InputEventKey:
var key_event := event as InputEventKey
var key_name := OS.get_keycode_string(key_event.keycode)
if key_event.ctrl_pressed:
key_name = "Ctrl+" + key_name
if key_event.alt_pressed:
key_name = "Alt+" + key_name
if key_event.shift_pressed:
key_name = "Shift+" + key_name
return key_name
elif event is InputEventMouseButton:
var mouse_event := event as InputEventMouseButton
match mouse_event.button_index:
MOUSE_BUTTON_LEFT:
return "Mouse Left"
MOUSE_BUTTON_RIGHT:
return "Mouse Right"
MOUSE_BUTTON_MIDDLE:
return "Mouse Middle"
_:
return "Mouse Button %d" % mouse_event.button_index
elif event is InputEventJoypadButton:
var joy_event := event as InputEventJoypadButton
return "Joypad Button %d" % joy_event.button_index
elif event is InputEventJoypadMotion:
var joy_motion := event as InputEventJoypadMotion
return "Joypad Axis %d" % joy_motion.axis
return event.as_text()
func _on_input_map_received(actions: Array, error: String) -> void:
_input_map_pending = false
if error.is_empty():
_input_map_result = {"actions": actions, "source": "game"}
else:
_input_map_result = {"error": error}
func execute_input_sequence(params: Dictionary) -> Dictionary:
var inputs: Array = params.get("inputs", [])
if inputs.is_empty():
return _error("INVALID_PARAMS", "inputs array is required and must not be empty")
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running")
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session")
var max_end_time: float = 0.0
for input in inputs:
var start_ms: float = input.get("start_ms", 0.0)
var duration_ms: float = input.get("duration_ms", 0.0)
max_end_time = max(max_end_time, start_ms + duration_ms)
var timeout := max(INPUT_TIMEOUT, (max_end_time / 1000.0) + 5.0)
_sequence_pending = true
_sequence_result = {}
debugger_plugin.input_sequence_completed.connect(_on_sequence_completed, CONNECT_ONE_SHOT)
debugger_plugin.request_input_sequence(inputs)
var start_time := Time.get_ticks_msec()
while _sequence_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
_sequence_pending = false
if debugger_plugin.input_sequence_completed.is_connected(_on_sequence_completed):
debugger_plugin.input_sequence_completed.disconnect(_on_sequence_completed)
return _error("TIMEOUT", "Timed out waiting for input sequence to complete")
if _sequence_result.has("error"):
return _error("SEQUENCE_ERROR", _sequence_result.get("error", "Unknown error"))
return _success(_sequence_result)
func _on_sequence_completed(result: Dictionary) -> void:
_sequence_pending = false
_sequence_result = result
func type_text(params: Dictionary) -> Dictionary:
var text: String = params.get("text", "")
var delay_ms: int = int(params.get("delay_ms", 50))
var submit: bool = params.get("submit", false)
if text.is_empty():
return _error("INVALID_PARAMS", "text is required and must not be empty")
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running")
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session")
var timeout := max(INPUT_TIMEOUT, (text.length() * delay_ms / 1000.0) + 5.0)
_type_text_pending = true
_type_text_result = {}
debugger_plugin.type_text_completed.connect(_on_type_text_completed, CONNECT_ONE_SHOT)
debugger_plugin.request_type_text(text, delay_ms, submit)
var start_time := Time.get_ticks_msec()
while _type_text_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > timeout:
_type_text_pending = false
if debugger_plugin.type_text_completed.is_connected(_on_type_text_completed):
debugger_plugin.type_text_completed.disconnect(_on_type_text_completed)
return _error("TIMEOUT", "Timed out waiting for text input to complete")
if _type_text_result.has("error"):
return _error("TYPE_TEXT_ERROR", _type_text_result.get("error", "Unknown error"))
return _success(_type_text_result)
func _on_type_text_completed(result: Dictionary) -> void:
_type_text_pending = false
_type_text_result = result

View File

@@ -0,0 +1 @@
uid://doirdhuupjsk

View File

@@ -0,0 +1,302 @@
@tool
extends MCPBaseCommand
class_name MCPNodeCommands
const FIND_NODES_TIMEOUT := 5.0
var _find_nodes_pending := false
var _find_nodes_result: Dictionary = {}
func get_commands() -> Dictionary:
return {
"get_node_properties": get_node_properties,
"find_nodes": find_nodes,
"create_node": create_node,
"update_node": update_node,
"delete_node": delete_node,
"reparent_node": reparent_node,
"connect_signal": connect_signal
}
func get_node_properties(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
var properties := {}
for prop in node.get_property_list():
var name: String = prop["name"]
if name.begins_with("_") or prop["usage"] & PROPERTY_USAGE_SCRIPT_VARIABLE == 0:
if prop["usage"] & PROPERTY_USAGE_EDITOR == 0:
continue
var value = node.get(name)
properties[name] = _serialize_value(value)
return _success({"properties": properties})
func find_nodes(params: Dictionary) -> Dictionary:
var name_pattern: String = params.get("name_pattern", "")
var type_filter: String = params.get("type", "")
var root_path: String = params.get("root_path", "")
if name_pattern.is_empty() and type_filter.is_empty():
return _error("INVALID_PARAMS", "At least one of name_pattern or type is required")
var debugger := _plugin.get_debugger_plugin() as MCPDebuggerPlugin
if debugger and EditorInterface.is_playing_scene() and debugger.has_active_session():
return await _find_nodes_via_game(debugger, name_pattern, type_filter, root_path)
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var scene_root := EditorInterface.get_edited_scene_root()
var search_root: Node = scene_root
if not root_path.is_empty():
search_root = _get_node(root_path)
if not search_root:
return _error("NODE_NOT_FOUND", "Root node not found: %s" % root_path)
var matches: Array[Dictionary] = []
_find_recursive(search_root, scene_root, name_pattern, type_filter, matches)
return _success({"matches": matches, "count": matches.size()})
func _find_nodes_via_game(debugger: MCPDebuggerPlugin, name_pattern: String, type_filter: String, root_path: String) -> Dictionary:
_find_nodes_pending = true
_find_nodes_result = {}
if debugger.find_nodes_received.is_connected(_on_find_nodes_received):
debugger.find_nodes_received.disconnect(_on_find_nodes_received)
debugger.find_nodes_received.connect(_on_find_nodes_received, CONNECT_ONE_SHOT)
debugger.request_find_nodes(name_pattern, type_filter, root_path)
var start_time := Time.get_ticks_msec()
while _find_nodes_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > FIND_NODES_TIMEOUT:
_find_nodes_pending = false
if debugger.find_nodes_received.is_connected(_on_find_nodes_received):
debugger.find_nodes_received.disconnect(_on_find_nodes_received)
return _error("TIMEOUT", "Game did not respond within %d seconds" % int(FIND_NODES_TIMEOUT))
return _find_nodes_result
func _on_find_nodes_received(matches: Array, count: int, error: String) -> void:
_find_nodes_pending = false
if not error.is_empty():
_find_nodes_result = _error("GAME_ERROR", error)
else:
_find_nodes_result = _success({"matches": matches, "count": count})
func _find_recursive(node: Node, scene_root: Node, name_pattern: String, type_filter: String, results: Array[Dictionary]) -> void:
var name_matches := name_pattern.is_empty() or node.name.matchn(name_pattern)
var type_matches := type_filter.is_empty() or node.is_class(type_filter)
if name_matches and type_matches:
var relative_path := scene_root.get_path_to(node)
var usable_path := "/root/" + scene_root.name
if relative_path != NodePath("."):
usable_path += "/" + str(relative_path)
results.append({
"path": usable_path,
"type": node.get_class()
})
for child in node.get_children():
_find_recursive(child, scene_root, name_pattern, type_filter, results)
func create_node(params: Dictionary) -> Dictionary:
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var parent_path: String = params.get("parent_path", "")
var node_type: String = params.get("node_type", "")
var scene_path: String = params.get("scene_path", "")
var node_name: String = params.get("node_name", "")
var properties: Dictionary = params.get("properties", {})
if parent_path.is_empty():
return _error("INVALID_PARAMS", "parent_path is required")
if node_name.is_empty():
return _error("INVALID_PARAMS", "node_name is required")
if node_type.is_empty() and scene_path.is_empty():
return _error("INVALID_PARAMS", "Either node_type or scene_path is required")
if not node_type.is_empty() and not scene_path.is_empty():
return _error("INVALID_PARAMS", "Provide node_type OR scene_path, not both")
var parent := _get_node(parent_path)
if not parent:
return _error("NODE_NOT_FOUND", "Parent node not found: %s" % parent_path)
var node: Node
if not scene_path.is_empty():
if not ResourceLoader.exists(scene_path):
return _error("SCENE_NOT_FOUND", "Scene not found: %s" % scene_path)
var packed_scene: PackedScene = load(scene_path)
if not packed_scene:
return _error("LOAD_FAILED", "Failed to load scene: %s" % scene_path)
node = packed_scene.instantiate(PackedScene.GEN_EDIT_STATE_INSTANCE)
if not node:
return _error("INSTANTIATE_FAILED", "Failed to instantiate: %s" % scene_path)
else:
if not ClassDB.class_exists(node_type):
return _error("INVALID_TYPE", "Unknown node type: %s" % node_type)
node = ClassDB.instantiate(node_type)
if not node:
return _error("CREATE_FAILED", "Failed to create node of type: %s" % node_type)
node.name = node_name
for key in properties:
if node.has_method("set") and key in node:
var deserialized := MCPUtils.deserialize_value(properties[key])
node.set(key, deserialized)
parent.add_child(node)
var scene_root := EditorInterface.get_edited_scene_root()
_set_owner_recursive(node, scene_root)
return _success({"node_path": str(scene_root.get_path_to(node))})
func _set_owner_recursive(node: Node, owner: Node) -> void:
node.owner = owner
for child in node.get_children():
_set_owner_recursive(child, owner)
func update_node(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var properties: Dictionary = params.get("properties", {})
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if properties.is_empty():
return _error("INVALID_PARAMS", "properties is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
for key in properties:
if key in node:
var deserialized := MCPUtils.deserialize_value(properties[key])
node.set(key, deserialized)
return _success({})
func delete_node(params: Dictionary) -> Dictionary:
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
var root := EditorInterface.get_edited_scene_root()
if node == root:
return _error("CANNOT_DELETE_ROOT", "Cannot delete the root node")
node.get_parent().remove_child(node)
node.queue_free()
return _success({})
func reparent_node(params: Dictionary) -> Dictionary:
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var node_path: String = params.get("node_path", "")
var new_parent_path: String = params.get("new_parent_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if new_parent_path.is_empty():
return _error("INVALID_PARAMS", "new_parent_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
var new_parent := _get_node(new_parent_path)
if not new_parent:
return _error("NODE_NOT_FOUND", "New parent not found: %s" % new_parent_path)
var root := EditorInterface.get_edited_scene_root()
if node == root:
return _error("CANNOT_REPARENT_ROOT", "Cannot reparent the root node")
if new_parent == node or node.is_ancestor_of(new_parent):
return _error("INVALID_REPARENT", "Cannot reparent a node to itself or its descendant")
node.reparent(new_parent)
return _success({"new_path": str(root.get_path_to(node))})
func connect_signal(params: Dictionary) -> Dictionary:
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var node_path: String = params.get("node_path", "")
var signal_name: String = params.get("signal_name", "")
var target_path: String = params.get("target_path", "")
var method_name: String = params.get("method_name", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if signal_name.is_empty():
return _error("INVALID_PARAMS", "signal_name is required")
if target_path.is_empty():
return _error("INVALID_PARAMS", "target_path is required")
if method_name.is_empty():
return _error("INVALID_PARAMS", "method_name is required")
var source_node := _get_node(node_path)
if not source_node:
return _error("NODE_NOT_FOUND", "Source node not found: %s" % node_path)
var target_node := _get_node(target_path)
if not target_node:
return _error("NODE_NOT_FOUND", "Target node not found: %s" % target_path)
if not source_node.has_signal(signal_name):
return _error("SIGNAL_NOT_FOUND", "Signal '%s' not found on node %s" % [signal_name, node_path])
if source_node.is_connected(signal_name, Callable(target_node, method_name)):
return _error("ALREADY_CONNECTED", "Signal '%s' is already connected to %s.%s()" % [signal_name, target_path, method_name])
var err := source_node.connect(signal_name, Callable(target_node, method_name), CONNECT_PERSIST)
if err != OK:
return _error("CONNECT_FAILED", "Failed to connect signal: %s" % error_string(err))
EditorInterface.mark_scene_as_unsaved()
return _success({})

View File

@@ -0,0 +1 @@
uid://hahpgho4qb0b

View File

@@ -0,0 +1,143 @@
@tool
extends MCPBaseCommand
class_name MCPProfilerCommands
const PROFILER_TIMEOUT := 5.0
const GENERIC_TIMEOUT := 5.0
var _performance_metrics_pending: bool = false
var _performance_metrics_result: Dictionary = {}
func get_commands() -> Dictionary:
return {
"get_performance_metrics": get_performance_metrics,
"start_profiler": start_profiler,
"stop_profiler": stop_profiler,
"get_profiler_data": get_profiler_data,
"get_active_processes": get_active_processes,
"get_signal_connections": get_signal_connections,
}
func get_performance_metrics(_params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running")
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session")
_performance_metrics_pending = true
_performance_metrics_result = {}
debugger_plugin.performance_metrics_received.connect(_on_performance_metrics_received, CONNECT_ONE_SHOT)
debugger_plugin.request_performance_metrics()
var start_time := Time.get_ticks_msec()
while _performance_metrics_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > PROFILER_TIMEOUT:
_performance_metrics_pending = false
if debugger_plugin.performance_metrics_received.is_connected(_on_performance_metrics_received):
debugger_plugin.performance_metrics_received.disconnect(_on_performance_metrics_received)
return _error("TIMEOUT", "Timed out waiting for performance metrics")
return _success(_performance_metrics_result)
func _on_performance_metrics_received(metrics: Dictionary) -> void:
_performance_metrics_pending = false
_performance_metrics_result = metrics
func start_profiler(_params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running")
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session")
debugger_plugin.toggle_frame_profiler(true)
return _success({"message": "Frame profiler started"})
func stop_profiler(_params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running")
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session")
debugger_plugin.toggle_frame_profiler(false)
return _success({"message": "Frame profiler stopped"})
func get_profiler_data(_params: Dictionary) -> Dictionary:
var result = await _send_and_wait("get_profiler_data")
if result == null:
return _last_error
var result_dict: Dictionary
if result is Dictionary:
result_dict = result
else:
result_dict = {"data": result}
return _success(result_dict)
func get_active_processes(_params: Dictionary) -> Dictionary:
var result = await _send_and_wait("get_active_processes")
if result == null:
return _last_error
var result_dict: Dictionary
if result is Dictionary:
result_dict = result
else:
result_dict = {"data": result}
return _success(result_dict)
func get_signal_connections(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var result = await _send_and_wait("get_signal_connections", [node_path])
if result == null:
return _last_error
var result_dict: Dictionary
if result is Dictionary:
result_dict = result
else:
result_dict = {"data": result}
return _success(result_dict)
var _last_error: Dictionary = {}
func _send_and_wait(msg_type: String, args: Array = []):
if not EditorInterface.is_playing_scene():
_last_error = _error("NOT_RUNNING", "No game is currently running")
return null
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null or not debugger_plugin.has_active_session():
_last_error = _error("NO_SESSION", "No active debug session")
return null
var sent: bool = debugger_plugin.send_game_message(msg_type, args)
if not sent:
_last_error = _error("SEND_FAILED", "Failed to send message to game")
return null
var start_time := Time.get_ticks_msec()
while not debugger_plugin.has_response(msg_type):
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > GENERIC_TIMEOUT:
debugger_plugin.clear_response(msg_type)
_last_error = _error("TIMEOUT", "Timed out waiting for %s response" % msg_type)
return null
var response = debugger_plugin.get_response(msg_type)
debugger_plugin.clear_response(msg_type)
return response

View File

@@ -0,0 +1 @@
uid://d3b5hmxyxcbsg

View File

@@ -0,0 +1,114 @@
@tool
extends MCPBaseCommand
class_name MCPProjectCommands
func get_commands() -> Dictionary:
return {
"get_project_info": get_project_info,
"get_project_settings": get_project_settings
}
func get_project_info(_params: Dictionary) -> Dictionary:
return _success({
"name": ProjectSettings.get_setting("application/config/name", "Unknown"),
"path": ProjectSettings.globalize_path("res://"),
"godot_version": Engine.get_version_info()["string"],
"main_scene": ProjectSettings.get_setting("application/run/main_scene", null)
})
func get_project_settings(params: Dictionary) -> Dictionary:
var category: String = params.get("category", "")
if category == "input":
return _get_input_mappings(params)
var settings := {}
var all_settings := ProjectSettings.get_property_list()
for prop in all_settings:
var name: String = prop["name"]
if not category.is_empty() and not name.begins_with(category):
continue
if prop["usage"] & PROPERTY_USAGE_EDITOR:
settings[name] = _serialize_value(ProjectSettings.get_setting(name))
return _success({"settings": settings})
func _get_input_mappings(params: Dictionary) -> Dictionary:
var include_builtin: bool = params.get("include_builtin", false)
var actions := {}
# Read from ProjectSettings instead of InputMap
# InputMap in editor context only has editor actions, not game inputs
# Game inputs are stored as "input/<action_name>" in ProjectSettings
var all_settings := ProjectSettings.get_property_list()
for prop in all_settings:
var name: String = prop["name"]
if not name.begins_with("input/"):
continue
var action_name := name.substr(6) # Remove "input/" prefix
if not include_builtin and action_name.begins_with("ui_"):
continue
var action_data = ProjectSettings.get_setting(name)
if action_data is Dictionary:
var events := []
var raw_events = action_data.get("events", [])
for event in raw_events:
if event is InputEvent:
events.append(_serialize_input_event(event))
actions[action_name] = {
"deadzone": action_data.get("deadzone", 0.5),
"events": events
}
return _success({"settings": actions})
func _serialize_input_event(event: InputEvent) -> Dictionary:
if event is InputEventKey:
var keycode: int = event.keycode if event.keycode else event.physical_keycode
return {
"type": "key",
"keycode": event.keycode,
"physical_keycode": event.physical_keycode,
"key_label": OS.get_keycode_string(keycode),
"modifiers": _get_modifiers(event)
}
elif event is InputEventMouseButton:
return {
"type": "mouse_button",
"button_index": event.button_index,
"modifiers": _get_modifiers(event)
}
elif event is InputEventJoypadButton:
return {
"type": "joypad_button",
"button_index": event.button_index,
"device": event.device
}
elif event is InputEventJoypadMotion:
return {
"type": "joypad_motion",
"axis": event.axis,
"axis_value": event.axis_value,
"device": event.device
}
return {"type": "unknown", "event": str(event)}
func _get_modifiers(event: InputEventWithModifiers) -> Dictionary:
return {
"shift": event.shift_pressed,
"ctrl": event.ctrl_pressed,
"alt": event.alt_pressed,
"meta": event.meta_pressed
}

View File

@@ -0,0 +1 @@
uid://dn711qsn11x65

View File

@@ -0,0 +1,293 @@
@tool
extends MCPBaseCommand
class_name MCPResourceCommands
const MAX_ARRAY_PREVIEW := 100
const BINARY_ARRAY_TYPES := [
TYPE_PACKED_BYTE_ARRAY,
TYPE_PACKED_INT32_ARRAY,
TYPE_PACKED_INT64_ARRAY,
TYPE_PACKED_FLOAT32_ARRAY,
TYPE_PACKED_FLOAT64_ARRAY,
]
func get_commands() -> Dictionary:
return {
"get_resource_info": get_resource_info
}
func get_resource_info(params: Dictionary) -> Dictionary:
var resource_path: String = params.get("resource_path", "")
var max_depth: int = params.get("max_depth", 1)
var include_internal: bool = params.get("include_internal", false)
if resource_path.is_empty():
return _error("INVALID_PARAMS", "resource_path is required")
if not ResourceLoader.exists(resource_path):
return _error("RESOURCE_NOT_FOUND", "Resource not found: %s" % resource_path)
var resource := load(resource_path)
if not resource:
return _error("LOAD_FAILED", "Failed to load resource: %s" % resource_path)
var result := {
"resource_path": resource_path,
"resource_type": resource.get_class()
}
var type_specific := _get_type_specific_info(resource, max_depth)
if not type_specific.is_empty():
result["type_specific"] = type_specific
else:
var properties := _get_generic_properties(resource, max_depth, include_internal)
if not properties.is_empty():
result["properties"] = properties
return _success(result)
func _get_type_specific_info(resource: Resource, max_depth: int) -> Dictionary:
if resource is SpriteFrames:
return _format_sprite_frames(resource, max_depth)
elif resource is TileSet:
return _format_tileset(resource, max_depth)
elif resource is ShaderMaterial:
return _format_shader_material(resource, max_depth)
elif resource is StandardMaterial3D or resource is ORMMaterial3D:
return _format_standard_material(resource, max_depth)
elif resource is Texture2D:
return _format_texture2d(resource)
return {}
func _format_sprite_frames(sf: SpriteFrames, max_depth: int) -> Dictionary:
var animations := []
for anim_name in sf.get_animation_names():
var anim_info := {
"name": str(anim_name),
"frame_count": sf.get_frame_count(anim_name),
"fps": sf.get_animation_speed(anim_name),
"loop": sf.get_animation_loop(anim_name),
}
if max_depth >= 1:
var frames := []
for i in range(sf.get_frame_count(anim_name)):
var texture := sf.get_frame_texture(anim_name, i)
var frame_info := {
"index": i,
"duration": sf.get_frame_duration(anim_name, i),
}
if texture:
frame_info["texture_type"] = texture.get_class()
if texture is AtlasTexture:
var atlas := texture as AtlasTexture
if atlas.atlas:
frame_info["atlas_source"] = atlas.atlas.resource_path
frame_info["region"] = _serialize_rect2(atlas.region)
if atlas.margin != Rect2():
frame_info["margin"] = _serialize_rect2(atlas.margin)
elif texture.resource_path:
frame_info["texture_path"] = texture.resource_path
frames.append(frame_info)
anim_info["frames"] = frames
animations.append(anim_info)
return {"animations": animations}
func _format_tileset(ts: TileSet, max_depth: int) -> Dictionary:
var sources := []
for i in range(ts.get_source_count()):
var source_id := ts.get_source_id(i)
var source := ts.get_source(source_id)
var source_info := {
"source_id": source_id,
"source_type": source.get_class(),
}
if source is TileSetAtlasSource:
var atlas := source as TileSetAtlasSource
if atlas.texture:
source_info["texture_path"] = atlas.texture.resource_path
source_info["texture_region_size"] = _serialize_vector2i(atlas.texture_region_size)
source_info["tile_count"] = atlas.get_tiles_count()
if max_depth >= 2:
var tiles := []
for j in range(atlas.get_tiles_count()):
var coords := atlas.get_tile_id(j)
tiles.append({
"atlas_coords": _serialize_vector2i(coords),
"size": _serialize_vector2i(atlas.get_tile_size_in_atlas(coords))
})
source_info["tiles"] = _truncate_array(tiles)
elif source is TileSetScenesCollectionSource:
var scenes := source as TileSetScenesCollectionSource
source_info["scene_count"] = scenes.get_scene_tiles_count()
sources.append(source_info)
return {
"tile_size": _serialize_vector2i(ts.tile_size),
"source_count": ts.get_source_count(),
"physics_layers_count": ts.get_physics_layers_count(),
"navigation_layers_count": ts.get_navigation_layers_count(),
"custom_data_layers_count": ts.get_custom_data_layers_count(),
"terrain_sets_count": ts.get_terrain_sets_count(),
"sources": sources
}
func _format_shader_material(mat: ShaderMaterial, max_depth: int) -> Dictionary:
var result := {
"shader_path": mat.shader.resource_path if mat.shader else ""
}
if mat.shader and max_depth >= 1:
var params := {}
for prop in mat.get_property_list():
var prop_name: String = prop["name"]
if prop_name.begins_with("shader_parameter/"):
var param_name := prop_name.substr(len("shader_parameter/"))
var value = mat.get_shader_parameter(param_name)
params[param_name] = _serialize_property_value(value, max_depth - 1)
if not params.is_empty():
result["shader_parameters"] = params
return result
func _format_standard_material(mat: BaseMaterial3D, max_depth: int) -> Dictionary:
var result := {
"albedo_color": _serialize_color(mat.albedo_color),
"metallic": mat.metallic,
"roughness": mat.roughness,
"emission_enabled": mat.emission_enabled,
"transparency": mat.transparency,
"cull_mode": mat.cull_mode,
"shading_mode": mat.shading_mode
}
if mat.albedo_texture:
result["albedo_texture"] = mat.albedo_texture.resource_path
if mat.emission_enabled and mat.emission_texture:
result["emission_texture"] = mat.emission_texture.resource_path
if mat.normal_enabled and mat.normal_texture:
result["normal_texture"] = mat.normal_texture.resource_path
return result
func _format_texture2d(tex: Texture2D) -> Dictionary:
var result := {
"width": tex.get_width(),
"height": tex.get_height(),
"texture_type": tex.get_class()
}
if tex is CompressedTexture2D:
var ct := tex as CompressedTexture2D
result["load_path"] = ct.load_path
if tex is AtlasTexture:
var at := tex as AtlasTexture
if at.atlas:
result["atlas_source"] = at.atlas.resource_path
result["region"] = _serialize_rect2(at.region)
if at.margin != Rect2():
result["margin"] = _serialize_rect2(at.margin)
return result
func _get_generic_properties(resource: Resource, max_depth: int, include_internal: bool) -> Dictionary:
var properties := {}
for prop in resource.get_property_list():
var prop_name: String = prop["name"]
if prop_name.begins_with("_") and not include_internal:
continue
if prop["usage"] & PROPERTY_USAGE_EDITOR == 0:
continue
if prop_name in ["resource_local_to_scene", "resource_path", "resource_name", "script"]:
continue
var value = resource.get(prop_name)
properties[prop_name] = _serialize_property_value(value, max_depth)
return properties
func _serialize_property_value(value: Variant, depth: int) -> Variant:
var value_type := typeof(value)
if value_type in BINARY_ARRAY_TYPES:
return {"_binary_array": true, "size": value.size(), "type": type_string(value_type)}
if value_type == TYPE_ARRAY:
if value.size() > MAX_ARRAY_PREVIEW:
var preview := []
for i in range(MAX_ARRAY_PREVIEW):
preview.append(_serialize_property_value(value[i], depth - 1) if depth > 0 else str(value[i]))
return {"_truncated": true, "size": value.size(), "preview": preview}
else:
var result := []
for item in value:
result.append(_serialize_property_value(item, depth - 1) if depth > 0 else str(item))
return result
if value_type == TYPE_DICTIONARY:
var result := {}
for key in value:
result[str(key)] = _serialize_property_value(value[key], depth - 1) if depth > 0 else str(value[key])
return result
if value_type == TYPE_OBJECT:
if value == null:
return null
if value is Resource:
if value.resource_path and not value.resource_path.is_empty():
return {"_resource_ref": value.resource_path, "type": value.get_class()}
elif depth > 0:
return {"_inline_resource": true, "type": value.get_class()}
return str(value)
return str(value)
return _serialize_value(value)
func _serialize_rect2(r: Rect2) -> Dictionary:
return {"x": r.position.x, "y": r.position.y, "width": r.size.x, "height": r.size.y}
func _serialize_vector2i(v: Vector2i) -> Dictionary:
return {"x": v.x, "y": v.y}
func _serialize_color(c: Color) -> Dictionary:
return {"r": c.r, "g": c.g, "b": c.b, "a": c.a}
func _truncate_array(arr: Array, limit: int = MAX_ARRAY_PREVIEW) -> Variant:
if arr.size() <= limit:
return arr
return {
"_truncated": true,
"size": arr.size(),
"preview": arr.slice(0, limit)
}

View File

@@ -0,0 +1 @@
uid://lkiiwjdnyop4

View File

@@ -0,0 +1,162 @@
@tool
extends MCPBaseCommand
class_name MCPScene3DCommands
func get_commands() -> Dictionary:
return {
"get_spatial_info": get_spatial_info,
"get_scene_bounds": get_scene_bounds,
}
func get_spatial_info(params: Dictionary) -> Dictionary:
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var node_path: String = params.get("node_path", "")
var include_children: bool = params.get("include_children", false)
var type_filter: String = params.get("type_filter", "")
var max_results: int = params.get("max_results", 0)
var within_aabb: Dictionary = params.get("within_aabb", {})
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
if not node is Node3D:
return _error("NOT_NODE3D", "Node is not a Node3D: %s" % node_path)
var filter_aabb: AABB = AABB()
var use_aabb_filter := false
if not within_aabb.is_empty():
var pos: Dictionary = within_aabb.get("position", {})
var size: Dictionary = within_aabb.get("size", {})
if pos.has("x") and size.has("x"):
filter_aabb = AABB(
Vector3(pos.get("x", 0), pos.get("y", 0), pos.get("z", 0)),
Vector3(size.get("x", 0), size.get("y", 0), size.get("z", 0))
)
use_aabb_filter = true
var nodes: Array[Dictionary] = []
var state := {"max": max_results, "count": 0, "stopped": false}
_collect_spatial_info(node, nodes, type_filter, include_children, use_aabb_filter, filter_aabb, state)
var result := {"nodes": nodes, "count": nodes.size()}
if state.stopped:
result["truncated"] = true
result["max_results"] = max_results
return _success(result)
func _collect_spatial_info(node: Node, results: Array[Dictionary], type_filter: String, include_children: bool, use_aabb_filter: bool, filter_aabb: AABB, state: Dictionary) -> void:
if state.max > 0 and state.count >= state.max:
state.stopped = true
return
if node is Node3D:
var node3d := node as Node3D
var type_matches := type_filter.is_empty() or node.is_class(type_filter)
var aabb_matches := true
if use_aabb_filter:
aabb_matches = filter_aabb.has_point(node3d.global_position)
if type_matches and aabb_matches:
results.append(_get_node3d_info(node3d))
state.count += 1
if include_children and not state.stopped:
for child in node.get_children():
_collect_spatial_info(child, results, type_filter, true, use_aabb_filter, filter_aabb, state)
if state.stopped:
break
func _get_node3d_info(node: Node3D) -> Dictionary:
var scene_root := EditorInterface.get_edited_scene_root()
var relative_path := scene_root.get_path_to(node)
var usable_path := "/root/" + scene_root.name
if relative_path != NodePath("."):
usable_path += "/" + str(relative_path)
var gpos: Vector3 = node.global_position
var grot: Vector3 = node.global_rotation
var gscale: Vector3 = node.global_transform.basis.get_scale()
var info := {
"path": usable_path,
"type": node.get_class(),
"global_position": {"x": gpos.x, "y": gpos.y, "z": gpos.z},
"global_rotation": {"x": grot.x, "y": grot.y, "z": grot.z},
"global_scale": {"x": gscale.x, "y": gscale.y, "z": gscale.z},
"visible": node.visible,
}
if node is VisualInstance3D:
var aabb := (node as VisualInstance3D).get_aabb()
var global_aabb := node.global_transform * aabb
info["aabb"] = _serialize_aabb(aabb)
info["global_aabb"] = _serialize_aabb(global_aabb)
return info
func _serialize_aabb(aabb: AABB) -> Dictionary:
return {
"position": _serialize_value(aabb.position),
"size": _serialize_value(aabb.size),
"end": _serialize_value(aabb.end),
}
func get_scene_bounds(params: Dictionary) -> Dictionary:
var scene_check := _require_scene_open()
if not scene_check.is_empty():
return scene_check
var root_path: String = params.get("root_path", "")
var scene_root := EditorInterface.get_edited_scene_root()
var search_root: Node = scene_root
if not root_path.is_empty():
search_root = _get_node(root_path)
if not search_root:
return _error("NODE_NOT_FOUND", "Root node not found: %s" % root_path)
var state := {"aabb": AABB(), "count": 0, "first": true}
_collect_bounds(search_root, state)
if state.count == 0:
return _error("NO_GEOMETRY", "No VisualInstance3D nodes found under: %s" % (root_path if not root_path.is_empty() else "scene root"))
var usable_path := "/root/" + scene_root.name
if search_root != scene_root:
var relative_path := scene_root.get_path_to(search_root)
usable_path += "/" + str(relative_path)
return _success({
"root_path": usable_path,
"node_count": state.count,
"combined_aabb": _serialize_aabb(state.aabb),
})
func _collect_bounds(node: Node, state: Dictionary) -> void:
if node is VisualInstance3D:
var visual := node as VisualInstance3D
var local_aabb := visual.get_aabb()
var global_aabb := visual.global_transform * local_aabb
if state.first:
state.aabb = global_aabb
state.first = false
else:
state.aabb = state.aabb.merge(global_aabb)
state.count += 1
for child in node.get_children():
_collect_bounds(child, state)

View File

@@ -0,0 +1 @@
uid://cfe6u1whveo5g

View File

@@ -0,0 +1,133 @@
@tool
extends MCPBaseCommand
class_name MCPSceneCommands
func get_commands() -> Dictionary:
return {
"get_current_scene": get_current_scene,
"get_scene_tree": get_scene_tree,
"open_scene": open_scene,
"save_scene": save_scene,
"create_scene": create_scene
}
func get_current_scene(_params: Dictionary) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if not root:
return _success({
"path": null,
"root_name": null,
"root_type": null
})
return _success({
"path": root.scene_file_path,
"root_name": root.name,
"root_type": root.get_class()
})
func get_scene_tree(_params: Dictionary) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if not root:
return _error("NO_SCENE", "No scene is currently open")
return _success({"tree": _build_tree(root)})
func _build_tree(node: Node) -> Dictionary:
var result := {
"name": node.name,
"type": node.get_class(),
}
if node is Node2D:
var pos: Vector2 = node.position
result["position"] = {"x": pos.x, "y": pos.y}
elif node is Node3D:
var pos: Vector3 = node.position
result["position"] = {"x": pos.x, "y": pos.y, "z": pos.z}
var children: Array[Dictionary] = []
for child in node.get_children():
children.append(_build_tree(child))
if not children.is_empty():
result["children"] = children
return result
func open_scene(params: Dictionary) -> Dictionary:
var scene_path: String = params.get("scene_path", "")
if scene_path.is_empty():
return _error("INVALID_PARAMS", "scene_path is required")
if not FileAccess.file_exists(scene_path):
return _error("FILE_NOT_FOUND", "Scene file not found: %s" % scene_path)
EditorInterface.open_scene_from_path(scene_path)
return _success({"path": scene_path})
func save_scene(params: Dictionary) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if not root:
return _error("NO_SCENE", "No scene is currently open")
var path: String = params.get("path", "")
if path.is_empty():
path = root.scene_file_path
if path.is_empty():
return _error("NO_PATH", "Scene has no path and none was provided")
var packed_scene := PackedScene.new()
var err := packed_scene.pack(root)
if err != OK:
return _error("PACK_FAILED", "Failed to pack scene: %s" % error_string(err))
err = ResourceSaver.save(packed_scene, path)
if err != OK:
return _error("SAVE_FAILED", "Failed to save scene: %s" % error_string(err))
return _success({"path": path})
func create_scene(params: Dictionary) -> Dictionary:
var root_type: String = params.get("root_type", "")
var root_name: String = params.get("root_name", root_type)
var scene_path: String = params.get("scene_path", "")
if root_type.is_empty():
return _error("INVALID_PARAMS", "root_type is required")
if scene_path.is_empty():
return _error("INVALID_PARAMS", "scene_path is required")
if not ClassDB.class_exists(root_type):
return _error("INVALID_TYPE", "Unknown node type: %s" % root_type)
var root: Node = ClassDB.instantiate(root_type)
if not root:
return _error("CREATE_FAILED", "Failed to create node of type: %s" % root_type)
root.name = root_name
var packed_scene := PackedScene.new()
var err := packed_scene.pack(root)
root.free()
if err != OK:
return _error("PACK_FAILED", "Failed to pack scene: %s" % error_string(err))
err = ResourceSaver.save(packed_scene, scene_path)
if err != OK:
return _error("SAVE_FAILED", "Failed to save scene: %s" % error_string(err))
EditorInterface.open_scene_from_path(scene_path)
var uid := ResourceUID.id_to_text(ResourceLoader.get_resource_uid(scene_path))
return _success({"path": scene_path, "uid": uid})

View File

@@ -0,0 +1 @@
uid://bv3do28j0hvxy

View File

@@ -0,0 +1,130 @@
@tool
extends MCPBaseCommand
class_name MCPScreenshotCommands
const DEFAULT_MAX_WIDTH := 1920
const SCREENSHOT_TIMEOUT := 5.0
var _screenshot_result: Dictionary = {}
var _screenshot_pending: bool = false
func get_commands() -> Dictionary:
return {
"capture_game_screenshot": capture_game_screenshot,
"capture_editor_screenshot": capture_editor_screenshot
}
func capture_game_screenshot(params: Dictionary) -> Dictionary:
if not EditorInterface.is_playing_scene():
return _error("NOT_RUNNING", "No game is currently running. Use run_project first.")
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
var debugger_plugin = _plugin.get_debugger_plugin() if _plugin else null
if debugger_plugin == null:
return _error("NO_DEBUGGER", "Debugger plugin not available")
if not debugger_plugin.has_active_session():
return _error("NO_SESSION", "No active debug session. Game may not have MCPGameBridge autoload.")
_screenshot_pending = true
_screenshot_result = {}
debugger_plugin.screenshot_received.connect(_on_screenshot_received, CONNECT_ONE_SHOT)
debugger_plugin.request_screenshot(max_width)
var start_time := Time.get_ticks_msec()
while _screenshot_pending:
await Engine.get_main_loop().process_frame
if (Time.get_ticks_msec() - start_time) / 1000.0 > SCREENSHOT_TIMEOUT:
_screenshot_pending = false
if debugger_plugin.screenshot_received.is_connected(_on_screenshot_received):
debugger_plugin.screenshot_received.disconnect(_on_screenshot_received)
return _error("TIMEOUT", "Screenshot request timed out")
return _screenshot_result
func _on_screenshot_received(success: bool, image_base64: String, width: int, height: int, error: String) -> void:
_screenshot_pending = false
if success:
_screenshot_result = _success({
"image_base64": image_base64,
"width": width,
"height": height
})
else:
_screenshot_result = _error("CAPTURE_FAILED", error)
func capture_editor_screenshot(params: Dictionary) -> Dictionary:
var viewport_type: String = params.get("viewport", "")
var max_width: int = params.get("max_width", DEFAULT_MAX_WIDTH)
var viewport: SubViewport = null
if viewport_type == "2d":
viewport = _find_2d_viewport()
elif viewport_type == "3d":
viewport = _find_3d_viewport()
else:
viewport = _find_active_viewport()
if viewport == null:
return _error("NO_VIEWPORT", "Could not find editor viewport")
var image := viewport.get_texture().get_image()
return _process_and_encode_image(image, max_width)
func _process_and_encode_image(image: Image, max_width: int) -> Dictionary:
if image == null:
return _error("CAPTURE_FAILED", "Failed to capture image from viewport")
if max_width > 0 and image.get_width() > max_width:
var scale_factor := float(max_width) / float(image.get_width())
var new_height := int(image.get_height() * scale_factor)
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
return _success({
"image_base64": base64,
"width": image.get_width(),
"height": image.get_height()
})
func _find_active_viewport() -> SubViewport:
var viewport := _find_3d_viewport()
if viewport:
return viewport
return _find_2d_viewport()
func _find_2d_viewport() -> SubViewport:
var editor_main := EditorInterface.get_editor_main_screen()
return _find_viewport_in_tree(editor_main, "2D")
func _find_3d_viewport() -> SubViewport:
var editor_main := EditorInterface.get_editor_main_screen()
return _find_viewport_in_tree(editor_main, "3D")
func _find_viewport_in_tree(node: Node, hint: String) -> SubViewport:
if node is SubViewportContainer:
var container := node as SubViewportContainer
for child in container.get_children():
if child is SubViewport:
return child as SubViewport
for child in node.get_children():
var result := _find_viewport_in_tree(child, hint)
if result:
return result
return null

View File

@@ -0,0 +1 @@
uid://dhvxaokhfr0w5

View File

@@ -0,0 +1,73 @@
@tool
extends MCPBaseCommand
class_name MCPScriptCommands
func get_commands() -> Dictionary:
return {
"get_current_script": get_current_script,
"attach_script": attach_script,
"detach_script": detach_script
}
func get_current_script(_params: Dictionary) -> Dictionary:
var script_editor := EditorInterface.get_script_editor()
if not script_editor:
return _success({"path": null, "content": null})
var current_script := script_editor.get_current_script()
if not current_script:
return _success({"path": null, "content": null})
return _success({
"path": current_script.resource_path,
"content": current_script.source_code
})
func attach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
var script_path: String = params.get("script_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if script_path.is_empty():
return _error("INVALID_PARAMS", "script_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
if not FileAccess.file_exists(script_path):
return _error("FILE_NOT_FOUND", "Script file not found: %s" % script_path)
var script := load(script_path) as Script
if not script:
return _error("LOAD_FAILED", "Failed to load script: %s" % script_path)
node.set_script(script)
EditorInterface.get_resource_filesystem().scan()
script.reload()
if node.get_script() != script:
return _error("ATTACH_FAILED", "Script attachment did not persist")
var scene_root := EditorInterface.get_edited_scene_root()
return _success({"node_path": str(scene_root.get_path_to(node)), "script_path": script_path})
func detach_script(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
node.set_script(null)
return _success({})

View File

@@ -0,0 +1 @@
uid://bka3ycfpg2ug

View File

@@ -0,0 +1,170 @@
@tool
extends MCPBaseCommand
class_name MCPSelectionCommands
func get_commands() -> Dictionary:
return {
"get_editor_state": get_editor_state,
"get_selected_nodes": get_selected_nodes,
"select_node": select_node,
"set_2d_viewport": set_2d_viewport
}
func get_editor_state(_params: Dictionary) -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
var open_scenes := EditorInterface.get_open_scenes()
var main_screen := _get_current_main_screen()
var result := {
"current_scene": root.scene_file_path if root else null,
"is_playing": EditorInterface.is_playing_scene(),
"godot_version": Engine.get_version_info()["string"],
"open_scenes": Array(open_scenes),
"main_screen": main_screen
}
if main_screen == "3D":
var camera_info := _get_editor_camera_info()
if not camera_info.is_empty():
result["camera"] = camera_info
elif main_screen == "2D":
var viewport_2d_info := _get_editor_2d_viewport_info()
if not viewport_2d_info.is_empty():
result["viewport_2d"] = viewport_2d_info
return _success(result)
func _get_editor_camera_info() -> Dictionary:
var viewport := EditorInterface.get_editor_viewport_3d(0)
if not viewport:
return {}
var camera := viewport.get_camera_3d()
if not camera:
return {}
var pos: Vector3 = camera.global_position
var rot: Vector3 = camera.global_rotation
var forward: Vector3 = -camera.global_transform.basis.z
var info := {
"position": {"x": pos.x, "y": pos.y, "z": pos.z},
"rotation": {"x": rot.x, "y": rot.y, "z": rot.z},
"forward": {"x": forward.x, "y": forward.y, "z": forward.z},
"fov": camera.fov,
"near": camera.near,
"far": camera.far,
"projection": "orthogonal" if camera.projection == Camera3D.PROJECTION_ORTHOGONAL else "perspective",
}
if camera.projection == Camera3D.PROJECTION_ORTHOGONAL:
info["size"] = camera.size
return info
func _get_editor_2d_viewport_info() -> Dictionary:
var viewport := EditorInterface.get_editor_viewport_2d()
if not viewport:
return {}
var transform := viewport.global_canvas_transform
var zoom: float = transform.x.x
var offset: Vector2 = -transform.origin / zoom
var size := viewport.size
return {
"center": {"x": offset.x + size.x / zoom / 2, "y": offset.y + size.y / zoom / 2},
"zoom": zoom,
"size": {"width": int(size.x), "height": int(size.y)}
}
func set_2d_viewport(params: Dictionary) -> Dictionary:
var viewport := EditorInterface.get_editor_viewport_2d()
if not viewport:
return _error("NO_VIEWPORT", "Could not access 2D editor viewport")
var center_x: float = params.get("center_x", 0.0)
var center_y: float = params.get("center_y", 0.0)
var zoom: float = params.get("zoom", 1.0)
if zoom <= 0:
return _error("INVALID_PARAMS", "zoom must be positive")
var size := viewport.size
var offset := Vector2(center_x - size.x / zoom / 2, center_y - size.y / zoom / 2)
var origin := -offset * zoom
var transform := Transform2D(Vector2(zoom, 0), Vector2(0, zoom), origin)
viewport.global_canvas_transform = transform
return _success({
"center": {"x": center_x, "y": center_y},
"zoom": zoom
})
const MAIN_SCREEN_PATTERNS := {
"2D": ["CanvasItemEditor", "2D"],
"3D": ["Node3DEditor", "3D"],
"Script": ["ScriptEditor", "Script"],
"AssetLib": ["AssetLib", "Asset"],
}
func _get_current_main_screen() -> String:
var main_screen := EditorInterface.get_editor_main_screen()
if not main_screen:
return "unknown"
for child in main_screen.get_children():
if child.visible and child is Control:
var cls := child.get_class()
var node_name := child.name
for screen_name in MAIN_SCREEN_PATTERNS:
var patterns: Array = MAIN_SCREEN_PATTERNS[screen_name]
if patterns[0] in cls or patterns[1] in node_name:
return screen_name
return "unknown"
func get_selected_nodes(_params: Dictionary) -> Dictionary:
var selection := EditorInterface.get_selection()
var root := EditorInterface.get_edited_scene_root()
var selected: Array[String] = []
for node in selection.get_selected_nodes():
if root and root.is_ancestor_of(node):
# Build clean path relative to scene root
var relative_path := root.get_path_to(node)
var usable_path := "/root/" + root.name
if relative_path != NodePath("."):
usable_path += "/" + str(relative_path)
selected.append(usable_path)
elif node == root:
selected.append("/root/" + root.name)
return _success({"selected": selected})
func select_node(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
var selection := EditorInterface.get_selection()
selection.clear()
selection.add_node(node)
return _success({})

View File

@@ -0,0 +1 @@
uid://mv0eiufscf2n

View File

@@ -0,0 +1,37 @@
@tool
extends MCPBaseCommand
class_name MCPSystemCommands
func get_commands() -> Dictionary:
return {
"mcp_handshake": mcp_handshake,
"heartbeat": heartbeat,
}
func mcp_handshake(params: Dictionary) -> Dictionary:
var server_version: String = params.get("server_version", "unknown")
if _plugin and _plugin.has_method("on_server_version_received"):
_plugin.on_server_version_received(server_version)
return _success({
"addon_version": _get_addon_version(),
"godot_version": Engine.get_version_info()["string"],
"project_path": ProjectSettings.globalize_path("res://"),
"project_name": ProjectSettings.get_setting("application/config/name", ""),
"server_version_received": server_version
})
func heartbeat(_params: Dictionary) -> Dictionary:
return _success({"status": "ok"})
func _get_addon_version() -> String:
var config := ConfigFile.new()
var err := config.load("res://addons/godot_mcp/plugin.cfg")
if err == OK:
return config.get_value("plugin", "version", "unknown")
return "unknown"

View File

@@ -0,0 +1 @@
uid://c22fvs7y7mjiu

View File

@@ -0,0 +1,665 @@
@tool
extends MCPBaseCommand
class_name MCPTilemapCommands
func get_commands() -> Dictionary:
return {
"list_tilemap_layers": list_tilemap_layers,
"get_tilemap_layer_info": get_tilemap_layer_info,
"get_tileset_info": get_tileset_info,
"get_used_cells": get_used_cells,
"get_cell": get_cell,
"set_cell": set_cell,
"erase_cell": erase_cell,
"clear_layer": clear_layer,
"get_cells_in_region": get_cells_in_region,
"set_cells_batch": set_cells_batch,
"convert_coords": convert_coords,
"list_gridmaps": list_gridmaps,
"get_gridmap_info": get_gridmap_info,
"get_meshlib_info": get_meshlib_info,
"get_gridmap_used_cells": get_gridmap_used_cells,
"get_gridmap_cell": get_gridmap_cell,
"set_gridmap_cell": set_gridmap_cell,
"clear_gridmap_cell": clear_gridmap_cell,
"clear_gridmap": clear_gridmap,
"get_cells_by_item": get_cells_by_item,
"set_gridmap_cells_batch": set_gridmap_cells_batch,
}
func _get_tilemap_layer(node_path: String) -> TileMapLayer:
var node := _get_node(node_path)
if not node:
return null
if not node is TileMapLayer:
return null
return node as TileMapLayer
func _get_gridmap(node_path: String) -> GridMap:
var node := _get_node(node_path)
if not node:
return null
if not node is GridMap:
return null
return node as GridMap
func _find_tilemap_layers(node: Node, result: Array, scene_root: Node) -> void:
if node is TileMapLayer:
result.append({
"path": str(scene_root.get_path_to(node)),
"name": node.name
})
for child in node.get_children():
_find_tilemap_layers(child, result, scene_root)
func _find_gridmaps(node: Node, result: Array, scene_root: Node) -> void:
if node is GridMap:
result.append({
"path": str(scene_root.get_path_to(node)),
"name": node.name
})
for child in node.get_children():
_find_gridmaps(child, result, scene_root)
func _serialize_vector2i(v: Vector2i) -> Dictionary:
return {"x": v.x, "y": v.y}
func _serialize_vector3i(v: Vector3i) -> Dictionary:
return {"x": v.x, "y": v.y, "z": v.z}
func _deserialize_vector2i(d: Dictionary) -> Vector2i:
return Vector2i(int(d.get("x", 0)), int(d.get("y", 0)))
func _deserialize_vector3i(d: Dictionary) -> Vector3i:
return Vector3i(int(d.get("x", 0)), int(d.get("y", 0)), int(d.get("z", 0)))
func list_tilemap_layers(params: Dictionary) -> Dictionary:
var root_path: String = params.get("root_path", "")
var scene_root := EditorInterface.get_edited_scene_root()
var root: Node
if not scene_root:
return _error("NO_SCENE", "No scene is open")
if root_path.is_empty():
root = scene_root
else:
root = _get_node(root_path)
if not root:
return _error("NODE_NOT_FOUND", "Root node not found")
var layers := []
_find_tilemap_layers(root, layers, scene_root)
return _success({"tilemap_layers": layers})
func get_tilemap_layer_info(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var tileset_path := ""
if layer.tile_set:
tileset_path = layer.tile_set.resource_path
return _success({
"name": layer.name,
"enabled": layer.enabled,
"tileset_path": tileset_path,
"cell_quadrant_size": layer.rendering_quadrant_size,
"collision_enabled": layer.collision_enabled,
"used_cells_count": layer.get_used_cells().size()
})
func get_tileset_info(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
if not layer.tile_set:
return _error("NO_TILESET", "TileMapLayer has no TileSet assigned")
var tileset := layer.tile_set
var sources := []
for i in range(tileset.get_source_count()):
var source_id := tileset.get_source_id(i)
var source := tileset.get_source(source_id)
var source_info := {
"source_id": source_id,
"source_type": "unknown"
}
if source is TileSetAtlasSource:
var atlas := source as TileSetAtlasSource
source_info["source_type"] = "atlas"
source_info["texture_path"] = atlas.texture.resource_path if atlas.texture else ""
source_info["texture_region_size"] = _serialize_vector2i(atlas.texture_region_size)
source_info["tile_count"] = atlas.get_tiles_count()
elif source is TileSetScenesCollectionSource:
source_info["source_type"] = "scenes_collection"
var scenes_source := source as TileSetScenesCollectionSource
source_info["scene_count"] = scenes_source.get_scene_tiles_count()
sources.append(source_info)
return _success({
"tileset_path": tileset.resource_path,
"tile_size": _serialize_vector2i(tileset.tile_size),
"source_count": tileset.get_source_count(),
"sources": sources
})
func get_used_cells(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var cells := layer.get_used_cells()
var result := []
for cell in cells:
result.append(_serialize_vector2i(cell))
return _success({"cells": result, "count": result.size()})
func get_cell(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("coords"):
return _error("INVALID_PARAMS", "coords is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var coords := _deserialize_vector2i(params["coords"])
var source_id := layer.get_cell_source_id(coords)
var atlas_coords := layer.get_cell_atlas_coords(coords)
var alt_tile := layer.get_cell_alternative_tile(coords)
if source_id == -1:
return _success({
"coords": _serialize_vector2i(coords),
"empty": true
})
return _success({
"coords": _serialize_vector2i(coords),
"empty": false,
"source_id": source_id,
"atlas_coords": _serialize_vector2i(atlas_coords),
"alternative_tile": alt_tile
})
func set_cell(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("coords"):
return _error("INVALID_PARAMS", "coords is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var coords := _deserialize_vector2i(params["coords"])
var source_id: int = params.get("source_id", 0)
var atlas_coords := Vector2i(0, 0)
if params.has("atlas_coords"):
atlas_coords = _deserialize_vector2i(params["atlas_coords"])
var alt_tile: int = params.get("alternative_tile", 0)
layer.set_cell(coords, source_id, atlas_coords, alt_tile)
return _success({
"coords": _serialize_vector2i(coords),
"source_id": source_id,
"atlas_coords": _serialize_vector2i(atlas_coords),
"alternative_tile": alt_tile
})
func erase_cell(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("coords"):
return _error("INVALID_PARAMS", "coords is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var coords := _deserialize_vector2i(params["coords"])
layer.erase_cell(coords)
return _success({"erased": _serialize_vector2i(coords)})
func clear_layer(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var count := layer.get_used_cells().size()
layer.clear()
return _success({"cleared": true, "cells_removed": count})
func get_cells_in_region(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("min_coords"):
return _error("INVALID_PARAMS", "min_coords is required")
if not params.has("max_coords"):
return _error("INVALID_PARAMS", "max_coords is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var min_coords := _deserialize_vector2i(params["min_coords"])
var max_coords := _deserialize_vector2i(params["max_coords"])
var cells := []
for cell in layer.get_used_cells():
if cell.x >= min_coords.x and cell.x <= max_coords.x and cell.y >= min_coords.y and cell.y <= max_coords.y:
var source_id := layer.get_cell_source_id(cell)
var atlas_coords := layer.get_cell_atlas_coords(cell)
var alt_tile := layer.get_cell_alternative_tile(cell)
cells.append({
"coords": _serialize_vector2i(cell),
"source_id": source_id,
"atlas_coords": _serialize_vector2i(atlas_coords),
"alternative_tile": alt_tile
})
return _success({"cells": cells, "count": cells.size()})
func set_cells_batch(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("cells"):
return _error("INVALID_PARAMS", "cells is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
var cells_data: Array = params["cells"]
var count := 0
for cell_data in cells_data:
if not cell_data.has("coords"):
continue
var coords := _deserialize_vector2i(cell_data["coords"])
var source_id: int = cell_data.get("source_id", 0)
var atlas_coords := Vector2i(0, 0)
if cell_data.has("atlas_coords"):
atlas_coords = _deserialize_vector2i(cell_data["atlas_coords"])
var alt_tile: int = cell_data.get("alternative_tile", 0)
layer.set_cell(coords, source_id, atlas_coords, alt_tile)
count += 1
return _success({"cells_set": count})
func convert_coords(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var layer := _get_tilemap_layer(node_path)
if not layer:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_TILEMAP_LAYER", "Node is not a TileMapLayer: %s" % node_path)
if params.has("local_position"):
var local_pos_data: Dictionary = params["local_position"]
var local_pos := Vector2(local_pos_data.get("x", 0.0), local_pos_data.get("y", 0.0))
var map_coords := layer.local_to_map(local_pos)
return _success({
"direction": "local_to_map",
"local_position": {"x": local_pos.x, "y": local_pos.y},
"map_coords": _serialize_vector2i(map_coords)
})
elif params.has("map_coords"):
var map_coords := _deserialize_vector2i(params["map_coords"])
var local_pos := layer.map_to_local(map_coords)
return _success({
"direction": "map_to_local",
"map_coords": _serialize_vector2i(map_coords),
"local_position": {"x": local_pos.x, "y": local_pos.y}
})
else:
return _error("INVALID_PARAMS", "Either local_position or map_coords is required")
func list_gridmaps(params: Dictionary) -> Dictionary:
var root_path: String = params.get("root_path", "")
var scene_root := EditorInterface.get_edited_scene_root()
var root: Node
if not scene_root:
return _error("NO_SCENE", "No scene is open")
if root_path.is_empty():
root = scene_root
else:
root = _get_node(root_path)
if not root:
return _error("NODE_NOT_FOUND", "Root node not found")
var gridmaps := []
_find_gridmaps(root, gridmaps, scene_root)
return _success({"gridmaps": gridmaps})
func get_gridmap_info(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var meshlib_path := ""
if gridmap.mesh_library:
meshlib_path = gridmap.mesh_library.resource_path
return _success({
"name": gridmap.name,
"mesh_library_path": meshlib_path,
"cell_size": _serialize_value(gridmap.cell_size),
"cell_center_x": gridmap.cell_center_x,
"cell_center_y": gridmap.cell_center_y,
"cell_center_z": gridmap.cell_center_z,
"used_cells_count": gridmap.get_used_cells().size()
})
func get_meshlib_info(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
if not gridmap.mesh_library:
return _error("NO_MESH_LIBRARY", "GridMap has no MeshLibrary assigned")
var meshlib := gridmap.mesh_library
var items := []
for i in range(meshlib.get_item_list().size()):
var item_id: int = meshlib.get_item_list()[i]
var item_name := meshlib.get_item_name(item_id)
var mesh := meshlib.get_item_mesh(item_id)
var mesh_path := mesh.resource_path if mesh else ""
items.append({
"index": item_id,
"name": item_name,
"mesh_path": mesh_path
})
return _success({
"mesh_library_path": meshlib.resource_path,
"item_count": meshlib.get_item_list().size(),
"items": items
})
func get_gridmap_used_cells(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var cells := gridmap.get_used_cells()
var result := []
for cell in cells:
result.append(_serialize_vector3i(cell))
return _success({"cells": result, "count": result.size()})
func get_gridmap_cell(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("coords"):
return _error("INVALID_PARAMS", "coords is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var coords := _deserialize_vector3i(params["coords"])
var item := gridmap.get_cell_item(coords)
var orientation := gridmap.get_cell_item_orientation(coords)
if item == GridMap.INVALID_CELL_ITEM:
return _success({
"coords": _serialize_vector3i(coords),
"empty": true
})
var item_name := ""
if gridmap.mesh_library:
item_name = gridmap.mesh_library.get_item_name(item)
return _success({
"coords": _serialize_vector3i(coords),
"empty": false,
"item": item,
"item_name": item_name,
"orientation": orientation
})
func set_gridmap_cell(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("coords"):
return _error("INVALID_PARAMS", "coords is required")
if not params.has("item"):
return _error("INVALID_PARAMS", "item is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var coords := _deserialize_vector3i(params["coords"])
var item: int = params["item"]
var orientation: int = params.get("orientation", 0)
gridmap.set_cell_item(coords, item, orientation)
return _success({
"coords": _serialize_vector3i(coords),
"item": item,
"orientation": orientation
})
func clear_gridmap_cell(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("coords"):
return _error("INVALID_PARAMS", "coords is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var coords := _deserialize_vector3i(params["coords"])
gridmap.set_cell_item(coords, GridMap.INVALID_CELL_ITEM)
return _success({"cleared": _serialize_vector3i(coords)})
func clear_gridmap(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var count := gridmap.get_used_cells().size()
gridmap.clear()
return _success({"cleared": true, "cells_removed": count})
func get_cells_by_item(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("item"):
return _error("INVALID_PARAMS", "item is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var item: int = params["item"]
var cells := gridmap.get_used_cells_by_item(item)
var result := []
for cell in cells:
result.append(_serialize_vector3i(cell))
return _success({"item": item, "cells": result, "count": result.size()})
func set_gridmap_cells_batch(params: Dictionary) -> Dictionary:
var node_path: String = params.get("node_path", "")
if node_path.is_empty():
return _error("INVALID_PARAMS", "node_path is required")
if not params.has("cells"):
return _error("INVALID_PARAMS", "cells is required")
var gridmap := _get_gridmap(node_path)
if not gridmap:
var node := _get_node(node_path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % node_path)
return _error("NOT_GRIDMAP", "Node is not a GridMap: %s" % node_path)
var cells_data: Array = params["cells"]
var count := 0
for cell_data in cells_data:
if not cell_data.has("coords") or not cell_data.has("item"):
continue
var coords := _deserialize_vector3i(cell_data["coords"])
var item: int = cell_data["item"]
var orientation: int = cell_data.get("orientation", 0)
gridmap.set_cell_item(coords, item, orientation)
count += 1
return _success({"cells_set": count})

View File

@@ -0,0 +1 @@
uid://cuducprbjgili

View File

@@ -0,0 +1,60 @@
@tool
class_name MCPBaseCommand
extends RefCounted
var _plugin: EditorPlugin
func setup(plugin: EditorPlugin) -> void:
_plugin = plugin
func get_commands() -> Dictionary:
return {}
func _success(result: Dictionary) -> Dictionary:
return MCPUtils.success(result)
func _error(code: String, message: String) -> Dictionary:
return MCPUtils.error(code, message)
func _get_node(path: String) -> Node:
return MCPUtils.get_node_from_path(path)
func _serialize_value(value: Variant) -> Variant:
return MCPUtils.serialize_value(value)
func _require_scene_open() -> Dictionary:
var root := EditorInterface.get_edited_scene_root()
if not root:
return _error("NO_SCENE", "No scene is currently open")
return {}
func _require_typed_node(path: String, type: String, type_error_code: String = "WRONG_TYPE") -> Variant:
var node := _get_node(path)
if not node:
return _error("NODE_NOT_FOUND", "Node not found: %s" % path)
if not node.is_class(type):
return _error(type_error_code, "Expected %s, got %s" % [type, node.get_class()])
return node
func _find_nodes_of_type(root: Node, type: String) -> Array[Dictionary]:
var result: Array[Dictionary] = []
var scene_root := EditorInterface.get_edited_scene_root()
if scene_root:
_find_nodes_recursive(root, type, result, scene_root)
return result
func _find_nodes_recursive(node: Node, type: String, result: Array[Dictionary], scene_root: Node) -> void:
if node.is_class(type):
result.append({"path": str(scene_root.get_path_to(node)), "name": node.name})
for child in node.get_children():
_find_nodes_recursive(child, type, result, scene_root)

View File

@@ -0,0 +1 @@
uid://btrhwuu4jmt7

View File

@@ -0,0 +1,5 @@
class_name MCPConstants
const LOCALHOST_BIND_ADDRESS = "127.0.0.1"
const PORT_MIN = 1
const PORT_MAX = 65535

View File

@@ -0,0 +1 @@
uid://daod4t3pjl3ce

View File

@@ -0,0 +1,283 @@
@tool
extends EditorDebuggerPlugin
class_name MCPDebuggerPlugin
signal screenshot_received(success: bool, image_base64: String, width: int, height: int, error: String)
signal debug_output_received(output: PackedStringArray)
signal performance_metrics_received(metrics: Dictionary)
signal find_nodes_received(matches: Array, count: int, error: String)
signal input_map_received(actions: Array, error: String)
signal input_sequence_completed(result: Dictionary)
signal type_text_completed(result: Dictionary)
signal game_response(message_type: String, data: Variant)
var _active_session_id: int = -1
var _pending_screenshot: bool = false
var _pending_debug_output: bool = false
var _pending_performance_metrics: bool = false
var _pending_find_nodes: bool = false
var _pending_input_map: bool = false
var _pending_input_sequence: bool = false
var _pending_type_text: bool = false
var _pending_requests: Dictionary = {}
var _responses: Dictionary = {}
func _has_capture(prefix: String) -> bool:
return prefix == "godot_mcp"
func _capture(message: String, data: Array, session_id: int) -> bool:
match message:
"godot_mcp:screenshot_result":
_handle_screenshot_result(data)
return true
"godot_mcp:debug_output_result":
_handle_debug_output_result(data)
return true
"godot_mcp:performance_metrics_result":
_handle_performance_metrics_result(data)
return true
"godot_mcp:find_nodes_result":
_handle_find_nodes_result(data)
return true
"godot_mcp:input_map_result":
_handle_input_map_result(data)
return true
"godot_mcp:input_sequence_result":
_handle_input_sequence_result(data)
return true
"godot_mcp:type_text_result":
_handle_type_text_result(data)
return true
"godot_mcp:game_response":
_handle_game_response(data)
return true
return false
func _setup_session(session_id: int) -> void:
_active_session_id = session_id
func _session_stopped() -> void:
_active_session_id = -1
if _pending_screenshot:
_pending_screenshot = false
screenshot_received.emit(false, "", 0, 0, "Game session ended")
if _pending_debug_output:
_pending_debug_output = false
debug_output_received.emit(PackedStringArray())
if _pending_performance_metrics:
_pending_performance_metrics = false
performance_metrics_received.emit({})
if _pending_find_nodes:
_pending_find_nodes = false
find_nodes_received.emit([], 0, "Game session ended")
if _pending_input_map:
_pending_input_map = false
input_map_received.emit([], "Game session ended")
if _pending_input_sequence:
_pending_input_sequence = false
input_sequence_completed.emit({"error": "Game session ended"})
if _pending_type_text:
_pending_type_text = false
type_text_completed.emit({"error": "Game session ended"})
for msg_type in _pending_requests:
_responses[msg_type] = {}
_pending_requests.clear()
func has_active_session() -> bool:
if _active_session_id < 0:
return false
if not EditorInterface.is_playing_scene():
_active_session_id = -1
return false
return true
func request_screenshot(max_width: int = 1920) -> void:
if _active_session_id < 0:
screenshot_received.emit(false, "", 0, 0, "No active game session")
return
_pending_screenshot = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:take_screenshot", [max_width])
else:
_pending_screenshot = false
screenshot_received.emit(false, "", 0, 0, "Could not get debugger session")
func _handle_screenshot_result(data: Array) -> void:
_pending_screenshot = false
if data.size() < 5:
screenshot_received.emit(false, "", 0, 0, "Invalid response data")
return
var success: bool = data[0]
var image_base64: String = data[1]
var width: int = data[2]
var height: int = data[3]
var error: String = data[4]
screenshot_received.emit(success, image_base64, width, height, error)
func request_debug_output(clear: bool = false) -> void:
if _active_session_id < 0:
debug_output_received.emit(PackedStringArray())
return
_pending_debug_output = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:get_debug_output", [clear])
else:
_pending_debug_output = false
debug_output_received.emit(PackedStringArray())
func _handle_debug_output_result(data: Array) -> void:
_pending_debug_output = false
var output: PackedStringArray = data[0] if data.size() > 0 else PackedStringArray()
debug_output_received.emit(output)
func request_performance_metrics() -> void:
if _active_session_id < 0:
performance_metrics_received.emit({})
return
_pending_performance_metrics = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:get_performance_metrics", [])
else:
_pending_performance_metrics = false
performance_metrics_received.emit({})
func _handle_performance_metrics_result(data: Array) -> void:
_pending_performance_metrics = false
var metrics: Dictionary = data[0] if data.size() > 0 else {}
performance_metrics_received.emit(metrics)
func request_find_nodes(name_pattern: String, type_filter: String, root_path: String) -> void:
if _active_session_id < 0:
find_nodes_received.emit([], 0, "No active game session")
return
_pending_find_nodes = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:find_nodes", [name_pattern, type_filter, root_path])
else:
_pending_find_nodes = false
find_nodes_received.emit([], 0, "Could not get debugger session")
func _handle_find_nodes_result(data: Array) -> void:
_pending_find_nodes = false
var matches: Array = data[0] if data.size() > 0 else []
var count: int = data[1] if data.size() > 1 else 0
var error: String = data[2] if data.size() > 2 else ""
find_nodes_received.emit(matches, count, error)
func request_input_map() -> void:
if _active_session_id < 0:
input_map_received.emit([], "No active game session")
return
_pending_input_map = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:get_input_map", [])
else:
_pending_input_map = false
input_map_received.emit([], "Could not get debugger session")
func _handle_input_map_result(data: Array) -> void:
_pending_input_map = false
var actions: Array = data[0] if data.size() > 0 else []
var error: String = data[1] if data.size() > 1 else ""
input_map_received.emit(actions, error)
func request_input_sequence(inputs: Array) -> void:
if _active_session_id < 0:
input_sequence_completed.emit({"error": "No active game session"})
return
_pending_input_sequence = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:execute_input_sequence", [inputs])
else:
_pending_input_sequence = false
input_sequence_completed.emit({"error": "Could not get debugger session"})
func _handle_input_sequence_result(data: Array) -> void:
_pending_input_sequence = false
var result: Dictionary = data[0] if data.size() > 0 else {}
input_sequence_completed.emit(result)
func request_type_text(text: String, delay_ms: int, submit: bool) -> void:
if _active_session_id < 0:
type_text_completed.emit({"error": "No active game session"})
return
_pending_type_text = true
var session := get_session(_active_session_id)
if session:
session.send_message("godot_mcp:type_text", [text, delay_ms, submit])
else:
_pending_type_text = false
type_text_completed.emit({"error": "Could not get debugger session"})
func _handle_type_text_result(data: Array) -> void:
_pending_type_text = false
var result: Dictionary = data[0] if data.size() > 0 else {}
type_text_completed.emit(result)
func send_game_message(msg_type: String, args: Array = []) -> bool:
if _active_session_id < 0:
return false
var session := get_session(_active_session_id)
if not session:
return false
_pending_requests[msg_type] = true
_responses.erase(msg_type)
session.send_message("godot_mcp:" + msg_type, args)
return true
func has_response(msg_type: String) -> bool:
return _responses.has(msg_type)
func get_response(msg_type: String) -> Variant:
return _responses.get(msg_type)
func clear_response(msg_type: String) -> void:
_responses.erase(msg_type)
_pending_requests.erase(msg_type)
func _handle_game_response(data: Array) -> void:
if data.size() < 2:
return
var msg_type: String = data[0]
var response_data: Variant = data[1]
_pending_requests.erase(msg_type)
_responses[msg_type] = response_data
game_response.emit(msg_type, response_data)
func toggle_frame_profiler(enable: bool) -> void:
if _active_session_id < 0:
return
var session := get_session(_active_session_id)
if session:
session.toggle_profiler("mcp_frame_profiler", enable)

View File

@@ -0,0 +1 @@
uid://cakxaiej4lb6w

View File

@@ -0,0 +1,10 @@
class_name MCPEnums
enum BindMode { LOCALHOST, WSL, CUSTOM }
static func get_mode_name(mode: BindMode) -> String:
match mode:
BindMode.LOCALHOST: return "Localhost"
BindMode.WSL: return "WSL"
BindMode.CUSTOM: return "Custom"
_: return "Unknown"

View File

@@ -0,0 +1 @@
uid://cdrfh4c15dks

View File

@@ -0,0 +1,14 @@
@tool
class_name MCPLog
extends RefCounted
const PREFIX := "[godot-mcp] "
static func info(message: String) -> void:
print(PREFIX + message)
static func warn(message: String) -> void:
push_warning(PREFIX + message)
static func error(message: String) -> void:
push_error(PREFIX + message)

View File

@@ -0,0 +1 @@
uid://bavtmnjx74t1l

View File

@@ -0,0 +1,92 @@
@tool
class_name MCPLogger extends Logger
static var _output: PackedStringArray = []
static var _errors: Array[Dictionary] = []
static var _max_lines := 1000
static var _max_errors := 100
static var _mutex := Mutex.new()
static func _static_init() -> void:
OS.add_logger(MCPLogger.new())
func _log_message(message: String, error: bool) -> void:
_mutex.lock()
var prefix := "[ERROR] " if error else ""
_output.append(prefix + message)
if _output.size() > _max_lines:
_output.remove_at(0)
_mutex.unlock()
func _log_error(function: String, file: String, line: int, code: String,
rationale: String, editor_notify: bool, error_type: int,
script_backtraces: Array[ScriptBacktrace]) -> void:
_mutex.lock()
var msg := "[%s:%d] %s: %s" % [file.get_file(), line, code, rationale]
_output.append("[ERROR] " + msg)
if _output.size() > _max_lines:
_output.remove_at(0)
var frames: Array[Dictionary] = []
for backtrace in script_backtraces:
for i in backtrace.get_frame_count():
frames.append({
"file": backtrace.get_frame_source(i),
"line": backtrace.get_frame_line(i),
"function": backtrace.get_frame_function(i),
})
var error_entry := {
"timestamp": Time.get_ticks_msec(),
"type": code,
"message": rationale,
"file": file,
"line": line,
"function": function,
"error_type": error_type,
"frames": frames,
}
if not _is_duplicate(error_entry):
_errors.append(error_entry)
if _errors.size() > _max_errors:
_errors.remove_at(0)
_mutex.unlock()
static func _is_duplicate(entry: Dictionary) -> bool:
if _errors.is_empty():
return false
var last := _errors[-1]
return (last.get("file") == entry.get("file")
and last.get("line") == entry.get("line")
and last.get("message") == entry.get("message")
and last.get("type") == entry.get("type"))
static func get_output() -> PackedStringArray:
return _output
static func get_errors() -> Array[Dictionary]:
return _errors
static func get_last_stack_trace() -> Array[Dictionary]:
if _errors.is_empty():
return []
return _errors[-1].get("frames", [])
static func clear() -> void:
_mutex.lock()
_output.clear()
_mutex.unlock()
static func clear_errors() -> void:
_mutex.lock()
_errors.clear()
_mutex.unlock()

View File

@@ -0,0 +1 @@
uid://dayisgpy78rci

View File

@@ -0,0 +1,129 @@
@tool
class_name MCPUtils
extends RefCounted
static func success(result: Dictionary) -> Dictionary:
return {
"status": "success",
"result": result
}
static func error(code: String, message: String) -> Dictionary:
return {
"status": "error",
"error": {
"code": code,
"message": message
}
}
static func get_node_from_path(path: String) -> Node:
var root := EditorInterface.get_edited_scene_root()
if not root:
return null
if path == "/root" or path == "/" or path == str(root.get_path()):
return root
if path.begins_with("/root/"):
var parts := path.split("/")
if parts.size() >= 3:
if parts[2] == root.name:
var relative_path := "/".join(parts.slice(3))
if relative_path.is_empty():
return root
return root.get_node_or_null(relative_path)
if path.begins_with("/"):
path = path.substr(1)
return root.get_node_or_null(path)
static func serialize_value(value: Variant) -> Variant:
match typeof(value):
TYPE_VECTOR2:
return {"x": value.x, "y": value.y}
TYPE_VECTOR2I:
return {"x": value.x, "y": value.y}
TYPE_VECTOR3:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_VECTOR3I:
return {"x": value.x, "y": value.y, "z": value.z}
TYPE_COLOR:
return {"r": value.r, "g": value.g, "b": value.b, "a": value.a}
TYPE_OBJECT:
if value == null:
return null
if value is Resource:
return value.resource_path if value.resource_path else str(value)
return str(value)
_:
return value
static func deserialize_value(value: Variant) -> Variant:
if value is String and value.begins_with("res://"):
var resource := load(value)
if resource:
return resource
if value is Dictionary:
if value.has("_resource"):
return _create_resource(value)
if value.has("x") and value.has("y"):
if value.has("z"):
return Vector3(value.x, value.y, value.z)
return Vector2(value.x, value.y)
if value.has("r") and value.has("g") and value.has("b"):
return Color(value.r, value.g, value.b, value.get("a", 1.0))
return value
static func _create_resource(spec: Dictionary) -> Resource:
var resource_type: String = spec.get("_resource", "")
if not ClassDB.class_exists(resource_type):
MCPLog.error("Unknown resource type: %s" % resource_type)
return null
if not ClassDB.is_parent_class(resource_type, "Resource"):
MCPLog.error("Type is not a Resource: %s" % resource_type)
return null
var resource: Resource = ClassDB.instantiate(resource_type)
if not resource:
MCPLog.error("Failed to create resource: %s" % resource_type)
return null
for key in spec:
if key == "_resource":
continue
if key in resource:
resource.set(key, deserialize_value(spec[key]))
return resource
static func is_resource_path(path: String) -> bool:
return path.begins_with("res://")
static func dir_exists(path: String) -> bool:
if path.is_empty():
return false
if is_resource_path(path):
var dir := DirAccess.open("res://")
return dir != null and dir.dir_exists(path.trim_prefix("res://"))
return DirAccess.dir_exists_absolute(path)
static func ensure_dir_exists(path: String) -> Error:
if dir_exists(path):
return OK
if is_resource_path(path):
var dir := DirAccess.open("res://")
if not dir:
return ERR_CANT_OPEN
return dir.make_dir_recursive(path.trim_prefix("res://"))
return DirAccess.make_dir_recursive_absolute(path)

View File

@@ -0,0 +1 @@
uid://c63cmeafr4oqh

View File

@@ -0,0 +1,61 @@
extends EngineProfiler
class_name MCPFrameProfiler
const MAX_FRAMES := 300
const MONITOR_SAMPLE_INTERVAL := 10
var _active := false
var _buffer: Array[Dictionary] = []
var _frame_index := 0
func _toggle(enable: bool, _options: Array) -> void:
_active = enable
if enable:
_buffer.clear()
_frame_index = 0
func _tick(frame_time: float, process_time: float, physics_time: float, physics_frame_time: float) -> void:
if not _active:
return
var entry := {
"ft": frame_time,
"pt": process_time,
"pht": physics_time,
"pft": physics_frame_time,
"i": _frame_index,
}
if _frame_index % MONITOR_SAMPLE_INTERVAL == 0:
entry["m"] = _snapshot_monitors()
_buffer.append(entry)
if _buffer.size() > MAX_FRAMES:
_buffer.pop_front()
_frame_index += 1
func get_buffer_data() -> Dictionary:
return {
"active": _active,
"frame_count": _buffer.size(),
"total_frames_collected": _frame_index,
"max_fps": Engine.max_fps,
"frames": _buffer.duplicate(),
}
func _snapshot_monitors() -> Dictionary:
return {
"fps": Performance.get_monitor(Performance.TIME_FPS),
"obj_count": int(Performance.get_monitor(Performance.OBJECT_COUNT)),
"node_count": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)),
"orphan_nodes": int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)),
"mem_static": int(Performance.get_monitor(Performance.MEMORY_STATIC)),
"render_objects": int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)),
"render_draw_calls": int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)),
"render_primitives": int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)),
}

View File

@@ -0,0 +1 @@
uid://cbefgyjtpbhqq

View File

@@ -0,0 +1,556 @@
extends Node
class_name MCPGameBridge
const DEFAULT_MAX_WIDTH := 1920
var _logger: _MCPGameLogger
var _profiler: MCPFrameProfiler
func _ready() -> void:
if not EngineDebugger.is_active():
return
_logger = _MCPGameLogger.new()
OS.add_logger(_logger)
_profiler = MCPFrameProfiler.new()
EngineDebugger.register_profiler("mcp_frame_profiler", _profiler)
EngineDebugger.register_message_capture("godot_mcp", _on_debugger_message)
MCPLog.info("Game bridge initialized")
func _exit_tree() -> void:
if EngineDebugger.is_active():
EngineDebugger.unregister_message_capture("godot_mcp")
if _profiler:
EngineDebugger.unregister_profiler("mcp_frame_profiler")
func _process(_delta: float) -> void:
if not _sequence_running or _sequence_events.is_empty():
return
var elapsed := Time.get_ticks_msec() - _sequence_start_time
while _sequence_events.size() > 0 and _sequence_events[0].time <= elapsed:
var seq_event: Dictionary = _sequence_events.pop_front()
var input_event := InputEventAction.new()
input_event.action = seq_event.action
input_event.pressed = seq_event.is_press
input_event.strength = 1.0 if seq_event.is_press else 0.0
Input.parse_input_event(input_event)
if not seq_event.is_press:
_actions_completed += 1
if _sequence_events.is_empty():
_sequence_running = false
set_process(false)
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
"completed": true,
"actions_executed": _actions_completed,
}])
var _sequence_events: Array = []
var _sequence_start_time: int = 0
var _sequence_running: bool = false
var _actions_completed: int = 0
var _actions_total: int = 0
func _on_debugger_message(message: String, data: Array) -> bool:
match message:
"take_screenshot":
_take_screenshot_deferred.call_deferred(data)
return true
"get_debug_output":
_handle_get_debug_output(data)
return true
"get_performance_metrics":
_handle_get_performance_metrics()
return true
"find_nodes":
_handle_find_nodes(data)
return true
"get_input_map":
_handle_get_input_map()
return true
"execute_input_sequence":
_handle_execute_input_sequence(data)
return true
"type_text":
_handle_type_text(data)
return true
"get_profiler_data":
_handle_get_profiler_data()
return true
"get_active_processes":
_handle_get_active_processes()
return true
"get_signal_connections":
_handle_get_signal_connections(data)
return true
return false
func _take_screenshot_deferred(data: Array) -> void:
var max_width: int = data[0] if data.size() > 0 else DEFAULT_MAX_WIDTH
await RenderingServer.frame_post_draw
_capture_and_send_screenshot(max_width)
func _capture_and_send_screenshot(max_width: int) -> void:
var viewport := get_viewport()
if viewport == null:
_send_screenshot_error("NO_VIEWPORT", "Could not get game viewport")
return
var image := viewport.get_texture().get_image()
if image == null:
_send_screenshot_error("CAPTURE_FAILED", "Failed to capture image from viewport")
return
if max_width > 0 and image.get_width() > max_width:
var scale_factor := float(max_width) / float(image.get_width())
var new_height := int(image.get_height() * scale_factor)
image.resize(max_width, new_height, Image.INTERPOLATE_LANCZOS)
var png_buffer := image.save_png_to_buffer()
var base64 := Marshalls.raw_to_base64(png_buffer)
EngineDebugger.send_message("godot_mcp:screenshot_result", [
true,
base64,
image.get_width(),
image.get_height(),
""
])
func _send_screenshot_error(code: String, message: String) -> void:
EngineDebugger.send_message("godot_mcp:screenshot_result", [
false,
"",
0,
0,
"%s: %s" % [code, message]
])
func _handle_get_debug_output(data: Array) -> void:
var clear: bool = data[0] if data.size() > 0 else false
var output := _logger.get_output() if _logger else PackedStringArray()
if clear and _logger:
_logger.clear()
EngineDebugger.send_message("godot_mcp:debug_output_result", [output])
func _handle_find_nodes(data: Array) -> void:
var name_pattern: String = data[0] if data.size() > 0 else ""
var type_filter: String = data[1] if data.size() > 1 else ""
var root_path: String = data[2] if data.size() > 2 else ""
var tree := get_tree()
var scene_root := tree.current_scene if tree else null
if not scene_root:
EngineDebugger.send_message("godot_mcp:find_nodes_result", [[], 0, "No scene running"])
return
var search_root: Node = scene_root
if not root_path.is_empty():
search_root = _get_node_from_path(root_path, scene_root)
if not search_root:
EngineDebugger.send_message("godot_mcp:find_nodes_result", [[], 0, "Root not found: " + root_path])
return
var matches: Array = []
_find_recursive(search_root, scene_root, name_pattern, type_filter, matches)
EngineDebugger.send_message("godot_mcp:find_nodes_result", [matches, matches.size(), ""])
func _get_node_from_path(path: String, scene_root: Node) -> Node:
if path == "/" or path.is_empty():
return scene_root
if path.begins_with("/root/"):
var parts := path.split("/")
if parts.size() >= 3 and parts[2] == scene_root.name:
var relative := "/".join(parts.slice(3))
if relative.is_empty():
return scene_root
return scene_root.get_node_or_null(relative)
if path.begins_with("/"):
path = path.substr(1)
return scene_root.get_node_or_null(path)
func _find_recursive(node: Node, scene_root: Node, name_pattern: String, type_filter: String, results: Array) -> void:
var name_matches := name_pattern.is_empty() or node.name.matchn(name_pattern)
var type_matches := type_filter.is_empty() or node.is_class(type_filter)
if name_matches and type_matches:
var path := "/root/" + scene_root.name
var relative := scene_root.get_path_to(node)
if relative != NodePath("."):
path += "/" + str(relative)
results.append({"path": path, "type": node.get_class()})
for child in node.get_children():
_find_recursive(child, scene_root, name_pattern, type_filter, results)
func _handle_get_performance_metrics() -> void:
var metrics := {
"fps": Performance.get_monitor(Performance.TIME_FPS),
"frame_time_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
"physics_time_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
"navigation_time_ms": Performance.get_monitor(Performance.TIME_NAVIGATION_PROCESS) * 1000.0,
"render_objects": int(Performance.get_monitor(Performance.RENDER_TOTAL_OBJECTS_IN_FRAME)),
"render_draw_calls": int(Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME)),
"render_primitives": int(Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME)),
"render_video_mem": int(Performance.get_monitor(Performance.RENDER_VIDEO_MEM_USED)),
"render_texture_mem": int(Performance.get_monitor(Performance.RENDER_TEXTURE_MEM_USED)),
"render_buffer_mem": int(Performance.get_monitor(Performance.RENDER_BUFFER_MEM_USED)),
"physics_2d_active_objects": int(Performance.get_monitor(Performance.PHYSICS_2D_ACTIVE_OBJECTS)),
"physics_2d_collision_pairs": int(Performance.get_monitor(Performance.PHYSICS_2D_COLLISION_PAIRS)),
"physics_2d_island_count": int(Performance.get_monitor(Performance.PHYSICS_2D_ISLAND_COUNT)),
"physics_3d_active_objects": int(Performance.get_monitor(Performance.PHYSICS_3D_ACTIVE_OBJECTS)),
"physics_3d_collision_pairs": int(Performance.get_monitor(Performance.PHYSICS_3D_COLLISION_PAIRS)),
"physics_3d_island_count": int(Performance.get_monitor(Performance.PHYSICS_3D_ISLAND_COUNT)),
"audio_output_latency": Performance.get_monitor(Performance.AUDIO_OUTPUT_LATENCY),
"object_count": int(Performance.get_monitor(Performance.OBJECT_COUNT)),
"object_resource_count": int(Performance.get_monitor(Performance.OBJECT_RESOURCE_COUNT)),
"object_node_count": int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT)),
"object_orphan_node_count": int(Performance.get_monitor(Performance.OBJECT_ORPHAN_NODE_COUNT)),
"memory_static": int(Performance.get_monitor(Performance.MEMORY_STATIC)),
"memory_static_max": int(Performance.get_monitor(Performance.MEMORY_STATIC_MAX)),
"memory_msg_buffer_max": int(Performance.get_monitor(Performance.MEMORY_MESSAGE_BUFFER_MAX)),
"navigation_active_maps": int(Performance.get_monitor(Performance.NAVIGATION_ACTIVE_MAPS)),
"navigation_region_count": int(Performance.get_monitor(Performance.NAVIGATION_REGION_COUNT)),
"navigation_agent_count": int(Performance.get_monitor(Performance.NAVIGATION_AGENT_COUNT)),
"navigation_link_count": int(Performance.get_monitor(Performance.NAVIGATION_LINK_COUNT)),
"navigation_polygon_count": int(Performance.get_monitor(Performance.NAVIGATION_POLYGON_COUNT)),
"navigation_edge_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_COUNT)),
"navigation_edge_merge_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_MERGE_COUNT)),
"navigation_edge_connection_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_CONNECTION_COUNT)),
"navigation_edge_free_count": int(Performance.get_monitor(Performance.NAVIGATION_EDGE_FREE_COUNT)),
"navigation_obstacle_count": int(Performance.get_monitor(Performance.NAVIGATION_OBSTACLE_COUNT)),
"pipeline_compilations_canvas": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_CANVAS)),
"pipeline_compilations_mesh": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_MESH)),
"pipeline_compilations_surface": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_SURFACE)),
"pipeline_compilations_draw": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_DRAW)),
"pipeline_compilations_specialization": int(Performance.get_monitor(Performance.PIPELINE_COMPILATIONS_SPECIALIZATION)),
}
var rid := get_viewport().get_viewport_rid()
metrics["viewport_render_cpu_ms"] = RenderingServer.viewport_get_measured_render_time_cpu(rid) + RenderingServer.viewport_get_measured_render_time_gpu(rid)
metrics["viewport_render_gpu_ms"] = RenderingServer.viewport_get_measured_render_time_gpu(rid)
EngineDebugger.send_message("godot_mcp:performance_metrics_result", [metrics])
func _handle_get_profiler_data() -> void:
var data := _profiler.get_buffer_data() if _profiler else {}
EngineDebugger.send_message("godot_mcp:game_response", ["get_profiler_data", data])
func _handle_get_active_processes() -> void:
var tree := get_tree()
var scene_root := tree.current_scene if tree else null
if not scene_root:
EngineDebugger.send_message("godot_mcp:game_response", ["get_active_processes", {"processes": []}])
return
var script_map: Dictionary = {}
_collect_processes(scene_root, scene_root, script_map)
var processes: Array = []
for script_path in script_map:
processes.append(script_map[script_path])
processes.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.instance_count > b.instance_count
)
EngineDebugger.send_message("godot_mcp:game_response", ["get_active_processes", {"processes": processes}])
func _collect_processes(node: Node, scene_root: Node, script_map: Dictionary) -> void:
var is_proc := node.is_processing()
var is_phys := node.is_physics_processing()
if is_proc or is_phys:
var script_path := ""
var script := node.get_script()
if script and script is Script:
script_path = script.resource_path
if script_path.is_empty():
script_path = node.get_class()
if not script_map.has(script_path):
script_map[script_path] = {
"script_path": script_path,
"has_process": false,
"has_physics_process": false,
"instance_count": 0,
"example_paths": [],
}
var entry: Dictionary = script_map[script_path]
if is_proc:
entry.has_process = true
if is_phys:
entry.has_physics_process = true
entry.instance_count += 1
if entry.example_paths.size() < 3:
var path := "/root/" + scene_root.name
var relative := scene_root.get_path_to(node)
if relative != NodePath("."):
path += "/" + str(relative)
entry.example_paths.append(path)
for child in node.get_children():
_collect_processes(child, scene_root, script_map)
func _handle_get_signal_connections(data: Array) -> void:
var node_path: String = data[0] if data.size() > 0 else ""
var tree := get_tree()
var scene_root := tree.current_scene if tree else null
if not scene_root:
EngineDebugger.send_message("godot_mcp:game_response", ["get_signal_connections", {"connections": []}])
return
var search_root: Node = scene_root
if not node_path.is_empty():
search_root = _get_node_from_path(node_path, scene_root)
if not search_root:
EngineDebugger.send_message("godot_mcp:game_response", ["get_signal_connections", {"connections": [], "error": "Node not found: " + node_path}])
return
var connections: Array = []
_collect_signal_connections(search_root, scene_root, connections, 0)
EngineDebugger.send_message("godot_mcp:game_response", ["get_signal_connections", {"connections": connections}])
const MAX_SIGNAL_CONNECTIONS := 200
const MAX_SIGNAL_DEPTH := 20
func _collect_signal_connections(node: Node, scene_root: Node, connections: Array, depth: int) -> void:
if connections.size() >= MAX_SIGNAL_CONNECTIONS or depth > MAX_SIGNAL_DEPTH:
return
var source_path := _node_path_string(node, scene_root)
for sig_info in node.get_signal_list():
var sig_name: String = sig_info.name
for conn in node.get_signal_connection_list(sig_name):
if connections.size() >= MAX_SIGNAL_CONNECTIONS:
return
var target: Object = conn.callable.get_object()
var target_path := ""
if target is Node:
target_path = _node_path_string(target as Node, scene_root)
else:
target_path = str(target)
connections.append({
"source_path": source_path,
"signal_name": sig_name,
"target_path": target_path,
"method_name": conn.callable.get_method(),
})
for child in node.get_children():
if connections.size() >= MAX_SIGNAL_CONNECTIONS:
return
_collect_signal_connections(child, scene_root, connections, depth + 1)
func _node_path_string(node: Node, scene_root: Node) -> String:
var path := "/root/" + scene_root.name
var relative := scene_root.get_path_to(node)
if relative != NodePath("."):
path += "/" + str(relative)
return path
class _MCPGameLogger extends Logger:
var _output: PackedStringArray = []
var _max_lines := 1000
var _mutex := Mutex.new()
func _log_message(message: String, error: bool) -> void:
_mutex.lock()
var prefix := "[ERROR] " if error else ""
_output.append(prefix + message)
if _output.size() > _max_lines:
_output.remove_at(0)
_mutex.unlock()
func _log_error(function: String, file: String, line: int, code: String,
rationale: String, editor_notify: bool, error_type: int,
script_backtraces: Array[ScriptBacktrace]) -> void:
_mutex.lock()
var msg := "[%s:%d] %s: %s" % [file.get_file(), line, code, rationale]
_output.append("[ERROR] " + msg)
if _output.size() > _max_lines:
_output.remove_at(0)
_mutex.unlock()
func get_output() -> PackedStringArray:
return _output
func clear() -> void:
_mutex.lock()
_output.clear()
_mutex.unlock()
func _handle_get_input_map() -> void:
var actions: Array = []
for action_name in InputMap.get_actions():
if action_name.begins_with("ui_"):
continue
var events := InputMap.action_get_events(action_name)
var event_strings: Array = []
for event in events:
event_strings.append(_event_to_string(event))
actions.append({
"name": action_name,
"events": event_strings,
})
EngineDebugger.send_message("godot_mcp:input_map_result", [actions, ""])
func _event_to_string(event: InputEvent) -> String:
if event is InputEventKey:
var key_event := event as InputEventKey
var key_name := OS.get_keycode_string(key_event.keycode)
if key_event.ctrl_pressed:
key_name = "Ctrl+" + key_name
if key_event.alt_pressed:
key_name = "Alt+" + key_name
if key_event.shift_pressed:
key_name = "Shift+" + key_name
return key_name
elif event is InputEventMouseButton:
var mouse_event := event as InputEventMouseButton
match mouse_event.button_index:
MOUSE_BUTTON_LEFT:
return "Mouse Left"
MOUSE_BUTTON_RIGHT:
return "Mouse Right"
MOUSE_BUTTON_MIDDLE:
return "Mouse Middle"
_:
return "Mouse Button %d" % mouse_event.button_index
elif event is InputEventJoypadButton:
var joy_event := event as InputEventJoypadButton
return "Joypad Button %d" % joy_event.button_index
elif event is InputEventJoypadMotion:
var joy_motion := event as InputEventJoypadMotion
return "Joypad Axis %d" % joy_motion.axis
return event.as_text()
func _handle_execute_input_sequence(data: Array) -> void:
var inputs: Array = data[0] if data.size() > 0 else []
if inputs.is_empty():
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
"error": "No inputs provided",
}])
return
_sequence_events.clear()
_actions_completed = 0
_actions_total = inputs.size()
for input in inputs:
var action_name: String = input.get("action_name", "")
var start_ms: int = int(input.get("start_ms", 0))
var duration_ms: int = int(input.get("duration_ms", 0))
if action_name.is_empty():
continue
if not InputMap.has_action(action_name):
EngineDebugger.send_message("godot_mcp:input_sequence_result", [{
"error": "Unknown action: %s" % action_name,
}])
return
_sequence_events.append({
"time": start_ms,
"action": action_name,
"is_press": true,
})
_sequence_events.append({
"time": start_ms + duration_ms,
"action": action_name,
"is_press": false,
})
_sequence_events.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.time < b.time
)
_sequence_start_time = Time.get_ticks_msec()
_sequence_running = true
set_process(true)
func _handle_type_text(data: Array) -> void:
var text: String = data[0] if data.size() > 0 else ""
var delay_ms: int = int(data[1]) if data.size() > 1 else 50
var submit: bool = data[2] if data.size() > 2 else false
if text.is_empty():
EngineDebugger.send_message("godot_mcp:type_text_result", [{
"error": "No text provided",
}])
return
_type_text_async(text, delay_ms, submit)
func _type_text_async(text: String, delay_ms: int, submit: bool) -> void:
for i in text.length():
var char_code := text.unicode_at(i)
var press := InputEventKey.new()
press.keycode = char_code
press.unicode = char_code
press.pressed = true
Input.parse_input_event(press)
var release := InputEventKey.new()
release.keycode = char_code
release.unicode = char_code
release.pressed = false
Input.parse_input_event(release)
if delay_ms > 0 and i < text.length() - 1:
await get_tree().create_timer(delay_ms / 1000.0).timeout
if submit:
if delay_ms > 0:
await get_tree().create_timer(delay_ms / 1000.0).timeout
var enter_press := InputEventKey.new()
enter_press.keycode = KEY_ENTER
enter_press.physical_keycode = KEY_ENTER
enter_press.pressed = true
Input.parse_input_event(enter_press)
var enter_release := InputEventKey.new()
enter_release.keycode = KEY_ENTER
enter_release.physical_keycode = KEY_ENTER
enter_release.pressed = false
Input.parse_input_event(enter_release)
EngineDebugger.send_message("godot_mcp:type_text_result", [{
"completed": true,
"chars_typed": text.length(),
"submitted": submit,
}])

View File

@@ -0,0 +1 @@
uid://c606pf78f127d

View File

@@ -0,0 +1,8 @@
[plugin]
name="Godot MCP"
description="Model Context Protocol server for AI assistant integration"
author="godot-mcp"
version="2.17.0"
script="plugin.gd"
godot_version_min="4.5"

View File

@@ -0,0 +1,303 @@
@tool
extends EditorPlugin
const WebSocketServer := preload("res://addons/godot_mcp/websocket_server.gd")
const CommandRouter := preload("res://addons/godot_mcp/command_router.gd")
const StatusPanel := preload("res://addons/godot_mcp/ui/status_panel.tscn")
const MCPDebuggerPlugin := preload("res://addons/godot_mcp/core/mcp_debugger_plugin.gd")
const GAME_BRIDGE_AUTOLOAD := "MCPGameBridge"
const GAME_BRIDGE_PATH := "res://addons/godot_mcp/game_bridge/mcp_game_bridge.gd"
const SETTING_BIND_MODE := "godot_mcp/bind_mode"
const SETTING_CUSTOM_BIND_IP := "godot_mcp/custom_bind_ip"
const SETTING_PORT_OVERRIDE_ENABLED := "godot_mcp/port_override_enabled"
const SETTING_PORT_OVERRIDE := "godot_mcp/port_override"
var _websocket_server: WebSocketServer
var _command_router: CommandRouter
var _status_panel: Control
var _debugger_plugin: MCPDebuggerPlugin
var _restart_timer: Timer
var _current_bind_address := MCPConstants.LOCALHOST_BIND_ADDRESS
var _current_bind_mode: MCPEnums.BindMode = MCPEnums.BindMode.LOCALHOST
func _enter_tree() -> void:
_command_router = CommandRouter.new()
_command_router.setup(self)
_websocket_server = WebSocketServer.new()
_websocket_server.command_received.connect(_on_command_received)
_websocket_server.client_connected.connect(_on_client_connected)
_websocket_server.client_disconnected.connect(_on_client_disconnected)
add_child(_websocket_server)
_status_panel = StatusPanel.instantiate()
add_control_to_bottom_panel(_status_panel, "MCP")
_debugger_plugin = MCPDebuggerPlugin.new()
add_debugger_plugin(_debugger_plugin)
_restart_timer = Timer.new()
_restart_timer.one_shot = true
_restart_timer.timeout.connect(_do_restart_server)
add_child(_restart_timer)
_ensure_game_bridge_autoload()
_ensure_bind_settings()
_setup_bind_ui()
_setup_version_display()
_apply_bind_settings(true)
MCPLog.info("Plugin initialized")
func _exit_tree() -> void:
if _restart_timer:
_restart_timer.stop()
_restart_timer.queue_free()
if _status_panel:
remove_control_from_bottom_panel(_status_panel)
_status_panel.queue_free()
if _websocket_server:
_websocket_server.stop_server()
_websocket_server.queue_free()
if _debugger_plugin:
remove_debugger_plugin(_debugger_plugin)
_debugger_plugin = null
if _command_router:
_command_router = null # RefCounted - freed automatically
MCPLog.info("Plugin disabled")
func _ensure_bind_settings() -> void:
if not ProjectSettings.has_setting(SETTING_BIND_MODE):
ProjectSettings.set_setting(SETTING_BIND_MODE, MCPEnums.BindMode.LOCALHOST)
if not ProjectSettings.has_setting(SETTING_CUSTOM_BIND_IP):
ProjectSettings.set_setting(SETTING_CUSTOM_BIND_IP, "")
if not ProjectSettings.has_setting(SETTING_PORT_OVERRIDE_ENABLED):
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE_ENABLED, false)
if not ProjectSettings.has_setting(SETTING_PORT_OVERRIDE):
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE, WebSocketServer.DEFAULT_PORT)
ProjectSettings.save()
func _setup_bind_ui() -> void:
if not _status_panel:
return
if _status_panel.has_method("set_config"):
_status_panel.set_config(_get_bind_mode(), _get_custom_bind_ip(), _get_port_override_enabled(), _get_port_override())
if _status_panel.has_signal("config_applied") and not _status_panel.config_applied.is_connected(_on_config_applied):
_status_panel.config_applied.connect(_on_config_applied)
func _get_bind_mode() -> MCPEnums.BindMode:
return ProjectSettings.get_setting(SETTING_BIND_MODE, MCPEnums.BindMode.LOCALHOST) as MCPEnums.BindMode
func _get_custom_bind_ip() -> String:
return str(ProjectSettings.get_setting(SETTING_CUSTOM_BIND_IP, ""))
func _get_port_override_enabled() -> bool:
return bool(ProjectSettings.get_setting(SETTING_PORT_OVERRIDE_ENABLED, false))
func _get_port_override() -> int:
var raw_value := ProjectSettings.get_setting(SETTING_PORT_OVERRIDE, WebSocketServer.DEFAULT_PORT)
var port := int(raw_value)
if port < MCPConstants.PORT_MIN or port > MCPConstants.PORT_MAX:
MCPLog.warn("Invalid port override '%s'; falling back to default port %d" % [str(raw_value), WebSocketServer.DEFAULT_PORT])
return WebSocketServer.DEFAULT_PORT
return port
func _get_listen_port() -> int:
return _get_port_override() if _get_port_override_enabled() else WebSocketServer.DEFAULT_PORT
func _resolve_bind_address() -> String:
match _get_bind_mode():
MCPEnums.BindMode.WSL:
var ip := _get_wsl_vethernet_ipv4()
if ip.is_empty():
MCPLog.warn("WSL bind mode selected but vEthernet (WSL) IPv4 was not found; falling back to %s" % MCPConstants.LOCALHOST_BIND_ADDRESS)
return MCPConstants.LOCALHOST_BIND_ADDRESS
return ip
MCPEnums.BindMode.CUSTOM:
var ip := _get_custom_bind_ip().strip_edges()
if ip.is_empty():
MCPLog.warn("Custom bind mode selected but no IP was configured; falling back to %s" % MCPConstants.LOCALHOST_BIND_ADDRESS)
return MCPConstants.LOCALHOST_BIND_ADDRESS
if not _is_valid_ipv4(ip):
MCPLog.warn("Custom bind mode selected but IP '%s' is not a valid IPv4 address; falling back to %s" % [ip, MCPConstants.LOCALHOST_BIND_ADDRESS])
return MCPConstants.LOCALHOST_BIND_ADDRESS
return ip
_:
return MCPConstants.LOCALHOST_BIND_ADDRESS
func _is_valid_ipv4(ip: String) -> bool:
var s := ip.strip_edges()
if s.is_empty():
return false
var parts := s.split(".")
if parts.size() != 4:
return false
for p in parts:
if p.is_empty() or not p.is_valid_int():
return false
var n := int(p)
if n < 0 or n > 255:
return false
return true
func _is_valid_bind_address(ip: String) -> bool:
if ip == "0.0.0.0" or ip == "127.0.0.1" or ip == "::" or ip == "::1":
return true
var local_ips := IP.get_local_addresses()
return ip in local_ips
func _get_wsl_vethernet_ipv4() -> String:
# Autodetect "vEthernet (WSL)" IPv4 via PowerShell (Windows only).
if OS.get_name() != "Windows":
return ""
var output := []
# Use ErrorAction Stop + catch so failures return an empty string and don't emit noisy errors.
# Match any adapter alias that contains "WSL" to be resilient to name variations.
# Note: The wildcard pattern 'vEthernet*WSL*' provides flexibility but may match
# unexpected adapters in custom network configurations. Document expected adapter names.
# SECURITY NOTE: Keep this PowerShell command as a fixed string literal. Do NOT concatenate
# user input, project settings, environment variables, or any other external data into it,
# as that could introduce command injection vulnerabilities. If dynamic behavior is needed,
# implement strict validation and avoid direct string interpolation into PowerShell.
var cmd := "try { $ip = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.InterfaceAlias -like 'vEthernet*WSL*' } | Select-Object -First 1 -ExpandProperty IPAddress); if ($ip) { $ip } else { '' } } catch { '' }"
var args := ["-NoProfile", "-Command", cmd]
var code := OS.execute("powershell", args, output, false)
if code != 0 or output.is_empty():
return ""
var text := String(output[0]).replace("\r", "")
for line in text.split("\n"):
var candidate := String(line).strip_edges()
if _is_valid_ipv4(candidate):
return candidate
return ""
func _restart_server() -> void:
# Debounce: stop any pending restart and schedule a new one
if _websocket_server:
_websocket_server.stop_server()
_restart_timer.start(0.1)
func _do_restart_server() -> void:
if not is_inside_tree() or not _websocket_server:
return
var bind := _resolve_bind_address()
# Verify IP is local
if not _is_valid_bind_address(bind):
MCPLog.error("IP '%s' is not assigned to any local network interface. Aborting bind." % bind)
MCPLog.warn("Please check your IP configuration and local network interfaces.")
_update_status("Error: IP %s not found on this machine" % bind)
return
var port := _get_listen_port()
_current_bind_address = bind
_current_bind_mode = _get_bind_mode()
var mode_name := MCPEnums.get_mode_name(_current_bind_mode)
var err := _websocket_server.start_server(port, bind)
if err != OK:
_update_status("Failed to bind %s:%d (%s)" % [bind, port, error_string(err)])
return
_update_status("Waiting for connection... (bind %s:%d [%s])" % [bind, port, mode_name])
MCPLog.info("Server listening on %s:%d [%s]" % [bind, port, mode_name])
func _apply_bind_settings(restart: bool) -> void:
_current_bind_address = _resolve_bind_address()
_current_bind_mode = _get_bind_mode()
if restart:
_restart_server()
else:
_update_status("Waiting for connection... (bind %s:%d [%s])" % [_current_bind_address, _get_listen_port(), MCPEnums.get_mode_name(_current_bind_mode)])
func _on_config_applied(config: Dictionary) -> void:
ProjectSettings.set_setting(SETTING_BIND_MODE, config.get("bind_mode", MCPEnums.BindMode.LOCALHOST))
ProjectSettings.set_setting(SETTING_CUSTOM_BIND_IP, str(config.get("custom_ip", "")))
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE_ENABLED, bool(config.get("port_override_enabled", false)))
ProjectSettings.set_setting(SETTING_PORT_OVERRIDE, int(config.get("port_override", WebSocketServer.DEFAULT_PORT)))
ProjectSettings.save()
_apply_bind_settings(true)
func _ensure_game_bridge_autoload() -> void:
if not ProjectSettings.has_setting("autoload/" + GAME_BRIDGE_AUTOLOAD):
ProjectSettings.set_setting("autoload/" + GAME_BRIDGE_AUTOLOAD, GAME_BRIDGE_PATH)
ProjectSettings.save()
MCPLog.info("Added MCPGameBridge autoload")
func get_debugger_plugin() -> MCPDebuggerPlugin:
return _debugger_plugin
func _on_command_received(id: String, command: String, params: Dictionary) -> void:
var response = await _command_router.handle_command(command, params)
response["id"] = id
_websocket_server.send_response(response)
func _on_client_connected() -> void:
var host_info := ""
if _websocket_server.get_connected_host():
host_info = " from %s:%d" % [_websocket_server.get_connected_host(), _websocket_server.get_connected_port()]
var bind_info := "(%s: %s:%d)" % [MCPEnums.get_mode_name(_current_bind_mode), _current_bind_address, _get_listen_port()]
_update_status("Connected%s %s" % [host_info, bind_info])
MCPLog.info("Client connected%s %s" % [host_info, bind_info])
func _on_client_disconnected() -> void:
_update_status("Disconnected")
if _status_panel and _status_panel.has_method("clear_server_version"):
_status_panel.clear_server_version()
MCPLog.info("Client disconnected")
func _update_status(status: String) -> void:
if _status_panel and _status_panel.has_method("set_status"):
_status_panel.set_status(status)
func _setup_version_display() -> void:
if _status_panel and _status_panel.has_method("set_addon_version"):
_status_panel.set_addon_version(_get_addon_version())
func _get_addon_version() -> String:
var config := ConfigFile.new()
var err := config.load("res://addons/godot_mcp/plugin.cfg")
if err == OK:
return config.get_value("plugin", "version", "")
return ""
func on_server_version_received(version: String) -> void:
if _status_panel and _status_panel.has_method("set_server_version"):
_status_panel.set_server_version(version)

View File

@@ -0,0 +1 @@
uid://bhl2ru4tj4sa8

View File

@@ -0,0 +1,226 @@
@tool
extends Control
signal config_applied(config: Dictionary)
func _get_minimum_size() -> Vector2:
return Vector2.ZERO
@onready var status_label: Label = $MarginContainer/VBoxContainer/StatusRow/StatusLabel
@onready var status_icon: ColorRect = $MarginContainer/VBoxContainer/StatusRow/StatusIcon
@onready var version_label: Label = $MarginContainer/VBoxContainer/StatusRow/VersionLabel
@onready var bind_mode_option: OptionButton = $MarginContainer/VBoxContainer/SettingsGrid/BindModeOption
@onready var custom_ip_label: Label = $MarginContainer/VBoxContainer/SettingsGrid/CustomIpLabel
@onready var custom_ip_edit: LineEdit = $MarginContainer/VBoxContainer/SettingsGrid/CustomIpEdit
@onready var port_override_label: Label = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideLabel
@onready var port_override_enabled: CheckBox = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls/PortOverrideEnabled
@onready var port_override_spin: SpinBox = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls/PortOverrideSpin
@onready var apply_button: Button = $MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls/ApplyButton
var _addon_version: String = ""
var _updating_ui := false
func _ready() -> void:
if bind_mode_option:
_updating_ui = true
bind_mode_option.clear()
bind_mode_option.add_item("Localhost", 0)
bind_mode_option.add_item("WSL", 1)
bind_mode_option.add_item("Custom", 2)
bind_mode_option.selected = 0
_updating_ui = false
bind_mode_option.item_selected.connect(_on_bind_mode_selected)
if apply_button:
apply_button.pressed.connect(_on_apply_pressed)
if port_override_enabled:
port_override_enabled.toggled.connect(_on_port_override_toggled)
if port_override_spin:
port_override_spin.value = 6550
# Keyboard navigation / focus
_for_control_focus(bind_mode_option)
_for_control_focus(custom_ip_edit)
_for_control_focus(port_override_enabled)
_for_control_focus(port_override_spin)
_for_control_focus(apply_button)
# Set up focus chain: each control points to the next in sequence
if bind_mode_option and (custom_ip_edit or apply_button):
bind_mode_option.focus_next = custom_ip_edit.get_path() if custom_ip_edit else apply_button.get_path()
if custom_ip_edit and (port_override_enabled or apply_button):
custom_ip_edit.focus_next = port_override_enabled.get_path() if port_override_enabled else apply_button.get_path()
if port_override_enabled and (port_override_spin or apply_button):
port_override_enabled.focus_next = port_override_spin.get_path() if port_override_spin else apply_button.get_path()
if port_override_spin and (apply_button or bind_mode_option):
port_override_spin.focus_next = apply_button.get_path() if apply_button else bind_mode_option.get_path()
if apply_button and (bind_mode_option or apply_button):
apply_button.focus_next = bind_mode_option.get_path() if bind_mode_option else apply_button.get_path()
_update_controls_enabled()
set_status("Initializing...")
func set_status(status: String) -> void:
if status_label:
status_label.text = status
if status_icon:
if status.begins_with("Connected"):
status_icon.color = Color.GREEN
elif status.begins_with("Disconnected") or status.begins_with("Waiting"):
status_icon.color = Color.ORANGE
else:
status_icon.color = Color.GRAY
func set_bind_mode(mode: MCPEnums.BindMode) -> void:
if not bind_mode_option:
return
_updating_ui = true
match mode:
MCPEnums.BindMode.WSL:
bind_mode_option.select(1)
MCPEnums.BindMode.CUSTOM:
bind_mode_option.select(2)
_:
bind_mode_option.select(0)
_updating_ui = false
_update_controls_enabled()
func get_bind_mode() -> MCPEnums.BindMode:
if not bind_mode_option:
return MCPEnums.BindMode.LOCALHOST
match bind_mode_option.selected:
1:
return MCPEnums.BindMode.WSL
2:
return MCPEnums.BindMode.CUSTOM
_:
return MCPEnums.BindMode.LOCALHOST
func set_custom_ip(ip: String) -> void:
if not custom_ip_edit:
return
_updating_ui = true
custom_ip_edit.text = ip
_updating_ui = false
func get_custom_ip() -> String:
return custom_ip_edit.text if custom_ip_edit else ""
func _on_bind_mode_selected(_idx: int) -> void:
if _updating_ui:
return
_update_controls_enabled()
func _on_port_override_toggled(_enabled: bool) -> void:
if _updating_ui:
return
_update_controls_enabled()
func _on_apply_pressed() -> void:
config_applied.emit(get_config())
func get_config() -> Dictionary:
return {
"bind_mode": get_bind_mode(),
"custom_ip": get_custom_ip(),
"port_override_enabled": port_override_enabled.button_pressed if port_override_enabled else false,
"port_override": int(port_override_spin.value) if port_override_spin else 6550,
}
func set_config(bind_mode: MCPEnums.BindMode, custom_ip: String, port_enabled: bool, port_value: int) -> void:
set_bind_mode(bind_mode)
set_custom_ip(custom_ip)
if port_override_enabled:
_updating_ui = true
port_override_enabled.button_pressed = port_enabled
_updating_ui = false
if port_override_spin:
_updating_ui = true
port_override_spin.value = clamp(port_value, 1, 65535)
_updating_ui = false
_update_controls_enabled()
func _for_control_focus(c: Control) -> void:
if not c:
return
c.focus_mode = Control.FOCUS_ALL
func _unhandled_key_input(event: InputEvent) -> void:
if not (event is InputEventKey):
return
var e := event as InputEventKey
if not e.pressed or e.echo:
return
if e.keycode == KEY_ENTER or e.keycode == KEY_KP_ENTER:
if apply_button:
_on_apply_pressed()
get_viewport().set_input_as_handled()
func _update_controls_enabled() -> void:
# Custom IP only editable in Custom mode
var custom_ip_enabled := get_bind_mode() == MCPEnums.BindMode.CUSTOM
if custom_ip_edit:
custom_ip_edit.editable = custom_ip_enabled
custom_ip_edit.modulate.a = 1.0 if custom_ip_enabled else 0.5
if custom_ip_label:
custom_ip_label.modulate.a = 1.0 if custom_ip_enabled else 0.5
# Port override controls
var port_enabled := port_override_enabled and port_override_enabled.button_pressed
if port_override_spin:
port_override_spin.editable = port_enabled
port_override_spin.modulate.a = 1.0 if port_enabled else 0.5
if port_override_label:
port_override_label.modulate.a = 1.0 if port_enabled else 0.5
func set_addon_version(version: String) -> void:
_addon_version = version
_update_version_label()
func set_server_version(version: String) -> void:
if not version_label:
return
if version.is_empty():
_update_version_label()
elif _addon_version.is_empty():
version_label.text = "Server: %s" % version
elif version == _addon_version:
version_label.text = "v%s" % version
else:
version_label.text = "Addon: %s | Server: %s" % [_addon_version, version]
version_label.add_theme_color_override("font_color", Color.ORANGE)
func clear_server_version() -> void:
_update_version_label()
if version_label:
version_label.remove_theme_color_override("font_color")
func _update_version_label() -> void:
if not version_label:
return
if _addon_version.is_empty():
version_label.text = ""
else:
version_label.text = "v%s" % _addon_version

View File

@@ -0,0 +1 @@
uid://cwg8sy28ab8as

View File

@@ -0,0 +1,101 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://addons/godot_mcp/ui/status_panel.gd" id="1"]
[node name="StatusPanel" type="Control"]
clip_contents = true
script = ExtResource("1")
[node name="MarginContainer" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 12
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 12
theme_override_constants/margin_bottom = 8
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="StatusRow" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/StatusRow"]
layout_mode = 2
text = "MCP Server:"
[node name="StatusIcon" type="ColorRect" parent="MarginContainer/VBoxContainer/StatusRow"]
custom_minimum_size = Vector2(12, 12)
layout_mode = 2
size_flags_vertical = 4
color = Color(0.5, 0.5, 0.5, 1)
[node name="StatusLabel" type="Label" parent="MarginContainer/VBoxContainer/StatusRow"]
layout_mode = 2
size_flags_horizontal = 3
text = "Initializing..."
[node name="VersionLabel" type="Label" parent="MarginContainer/VBoxContainer/StatusRow"]
layout_mode = 2
theme_override_colors/font_color = Color(0.6, 0.6, 0.6, 1)
text = ""
[node name="HSeparator" type="HSeparator" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
[node name="SettingsGrid" type="GridContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
columns = 2
theme_override_constants/h_separation = 16
theme_override_constants/v_separation = 8
[node name="BindLabel" type="Label" parent="MarginContainer/VBoxContainer/SettingsGrid"]
layout_mode = 2
text = "Bind mode"
[node name="BindModeOption" type="OptionButton" parent="MarginContainer/VBoxContainer/SettingsGrid"]
custom_minimum_size = Vector2(120, 0)
layout_mode = 2
[node name="CustomIpLabel" type="Label" parent="MarginContainer/VBoxContainer/SettingsGrid"]
layout_mode = 2
text = "Custom bind IP"
[node name="CustomIpEdit" type="LineEdit" parent="MarginContainer/VBoxContainer/SettingsGrid"]
custom_minimum_size = Vector2(180, 0)
layout_mode = 2
placeholder_text = "e.g. 127.0.0.1"
[node name="PortOverrideLabel" type="Label" parent="MarginContainer/VBoxContainer/SettingsGrid"]
layout_mode = 2
text = "Port override"
[node name="PortOverrideControls" type="HBoxContainer" parent="MarginContainer/VBoxContainer/SettingsGrid"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="PortOverrideEnabled" type="CheckBox" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
layout_mode = 2
text = "Enable"
[node name="PortOverrideSpin" type="SpinBox" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
layout_mode = 2
min_value = 1.0
max_value = 65535.0
step = 1.0
value = 6550.0
allow_greater = false
allow_lesser = false
rounded = true
[node name="Spacer" type="Control" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
layout_mode = 2
size_flags_horizontal = 3
[node name="ApplyButton" type="Button" parent="MarginContainer/VBoxContainer/SettingsGrid/PortOverrideControls"]
layout_mode = 2
text = "Apply"

View File

@@ -0,0 +1,216 @@
@tool
extends Node
class_name MCPWebSocketServer
signal command_received(id: String, command: String, params: Dictionary)
signal client_connected()
signal client_disconnected()
const DEFAULT_PORT := 6550
const STALE_CONNECTION_TIMEOUT_MSEC := 45000
const CLOSE_CODE_STALE := 4002
const CLOSE_REASON_STALE := "Connection timed out (no activity)"
const CLOSE_CODE_REPLACED := 4003
const CLOSE_REASON_REPLACED := "Replaced by new client"
var _server: TCPServer
var _peer: StreamPeerTCP
var _ws_peer: WebSocketPeer
var _is_connected := false
var _connected_host: String = ""
var _connected_port: int = 0
var _last_activity_msec: int = 0
var _stale_reason: String = ""
func _process(_delta: float) -> void:
if not _server:
return
if _server.is_connection_available():
_accept_connection()
if _ws_peer:
_ws_peer.poll()
_process_websocket()
func start_server(port: int = DEFAULT_PORT, bind_address: String = "127.0.0.1") -> Error:
_server = TCPServer.new()
var err := _server.listen(port, bind_address)
if err != OK:
_server = null
MCPLog.error("Failed to start server on %s:%d: %s" % [bind_address, port, error_string(err)])
return err
return OK
func stop_server() -> void:
if _ws_peer:
_ws_peer.close()
_ws_peer = null
if _peer:
_peer.disconnect_from_host()
_peer = null
if _server:
_server.stop()
_server = null
_is_connected = false
_connected_host = ""
_connected_port = 0
_last_activity_msec = 0
func get_connected_host() -> String:
return _connected_host
func get_connected_port() -> int:
return _connected_port
func send_response(response: Dictionary) -> void:
if not _ws_peer or _ws_peer.get_ready_state() != WebSocketPeer.STATE_OPEN:
MCPLog.warn("Cannot send response: not connected")
return
var json := JSON.stringify(response)
_ws_peer.send_text(json)
func _accept_connection() -> void:
var incoming := _server.take_connection()
if not incoming:
return
if _ws_peer != null:
if _is_stale_connection():
MCPLog.warn("Replacing stale connection with new client (%s)" % _stale_reason)
_force_close_connection()
else:
MCPLog.warn("Replacing active connection with new client (previous server likely exited without closing)")
_force_close_connection(CLOSE_CODE_REPLACED, CLOSE_REASON_REPLACED)
_peer = incoming
_ws_peer = WebSocketPeer.new()
_ws_peer.outbound_buffer_size = 16 * 1024 * 1024 # 16MB for screenshot data
var err := _ws_peer.accept_stream(_peer)
if err != OK:
MCPLog.error("Failed to accept WebSocket stream: %s" % error_string(err))
_ws_peer = null
_peer = null
return
_connected_host = _peer.get_connected_host()
_connected_port = _peer.get_connected_port()
_last_activity_msec = Time.get_ticks_msec()
MCPLog.info("TCP connection received from %s:%d, awaiting WebSocket handshake..." % [_connected_host, _connected_port])
func _process_websocket() -> void:
if not _ws_peer:
return
var state := _ws_peer.get_ready_state()
match state:
WebSocketPeer.STATE_CONNECTING:
pass
WebSocketPeer.STATE_OPEN:
if not _is_connected:
_is_connected = true
_last_activity_msec = Time.get_ticks_msec()
client_connected.emit()
MCPLog.info("WebSocket handshake complete")
if _is_stale_connection():
MCPLog.warn("Closing stale connection (%s)" % _stale_reason)
_ws_peer.close(CLOSE_CODE_STALE, CLOSE_REASON_STALE)
return
while _ws_peer.get_available_packet_count() > 0:
_last_activity_msec = Time.get_ticks_msec()
var packet := _ws_peer.get_packet()
_handle_packet(packet)
WebSocketPeer.STATE_CLOSING:
pass
WebSocketPeer.STATE_CLOSED:
if _is_connected:
_is_connected = false
client_disconnected.emit()
_ws_peer = null
_peer = null
func _force_close_connection(close_code: int = CLOSE_CODE_STALE, close_reason: String = CLOSE_REASON_STALE) -> void:
if _ws_peer:
_ws_peer.close(close_code, close_reason)
_ws_peer = null
if _peer:
_peer.disconnect_from_host()
_peer = null
if _is_connected:
_is_connected = false
client_disconnected.emit()
_last_activity_msec = 0
_connected_host = ""
_connected_port = 0
func _is_stale_connection() -> bool:
if _last_activity_msec == 0:
return false
if _peer and _peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
_stale_reason = "TCP peer disconnected"
return true
if Time.get_ticks_msec() - _last_activity_msec > STALE_CONNECTION_TIMEOUT_MSEC:
_stale_reason = "no activity for %ds" % (STALE_CONNECTION_TIMEOUT_MSEC / 1000)
return true
return false
func _handle_packet(packet: PackedByteArray) -> void:
var text := packet.get_string_from_utf8()
var json := JSON.new()
var err := json.parse(text)
if err != OK:
MCPLog.error("Failed to parse command: %s" % json.get_error_message())
_send_error_response("", "PARSE_ERROR", "Invalid JSON: %s" % json.get_error_message())
return
if not json.data is Dictionary:
MCPLog.error("Invalid command format: expected JSON object")
_send_error_response("", "INVALID_FORMAT", "Expected JSON object")
return
var data: Dictionary = json.data
if not data.has("id") or not data.has("command"):
MCPLog.error("Invalid command format")
_send_error_response(data.get("id", ""), "INVALID_FORMAT", "Missing 'id' or 'command' field")
return
var id: String = str(data.get("id"))
var command: String = data.get("command")
var params: Dictionary = data.get("params", {})
command_received.emit(id, command, params)
func _send_error_response(id: String, code: String, message: String) -> void:
send_response({
"id": id,
"status": "error",
"error": {
"code": code,
"message": message
}
})

View File

@@ -0,0 +1 @@
uid://bkk3o37h86b85

1
ruf-der-pilze/icon.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="124" height="124" x="2" y="2" fill="#363d52" stroke="#212532" stroke-width="4" rx="14"/><g fill="#fff" transform="translate(12.322 12.322)scale(.101)"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 814 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H446l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c0 34 58 34 58 0v-86c0-34-58-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042" transform="translate(12.322 12.322)scale(.101)"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1,43 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ddho0we3wmje5"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View File

@@ -0,0 +1,38 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Ruf der Pilze"
config/features=PackedStringArray("4.6", "Forward Plus")
config/icon="res://icon.svg"
[autoload]
MCPGameBridge="res://addons/godot_mcp/game_bridge/mcp_game_bridge.gd"
[editor_plugins]
enabled=PackedStringArray("res://addons/godot_mcp/plugin.cfg")
[godot_mcp]
bind_mode=0
custom_bind_ip=""
port_override_enabled=false
port_override=6550
[physics]
3d/physics_engine="Jolt Physics"
[rendering]
rendering_device/driver.windows="d3d12"