Files
tactical-shooter/addons/godot_mcp/tools/script_tools.gd
T
shawn b0c83af092 Fresh start: replace with naxIO/netfox-cs-sample foundation
Complete replacement of the tactical-shooter project with the
netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built
with Godot 4 and netfox.

## What's new
- Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu
- 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP
- Bomb plant/defuse with 2 bombsites
- Flashbang & smoke grenades
- Proper netfox rollback netcode at 64 tick
- Network popup UI for host/join
- HUD, crosshair, round timer, scoreboard
- All netfox singletons registered as autoloads (works in exported builds)

## Architecture
- Listen-server (host from client, no dedicated server binary)
- Multiplayer-fps game lives at examples/multiplayer-fps/
- Netfox addons registered as autoloads for exported build compat
- Godot 4.7 with Forward+ renderer

## Removed
- Old headless-server architecture (client_main, server_main, player.gd, etc.)
- Custom netfox bootstrap with ENet fallback
- Old ChaffGames FPS template (2,420 lines, 844 KB)
- SimulationServer GDExtension stub
- Godot-jolt physics (netfox sample uses default Godot physics)
- Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc.
- Server browser API Python venv (87 MB)
- test_range map and modular assets

## Preserved
- Git history
- Server config at config/default_server_config.cfg
- Windows export preset
- Build directory (gitignored)

Co-authored-by: naxIO <naxIO@users.noreply.github.com>
2026-07-02 20:55:20 -04:00

334 lines
11 KiB
GDScript

@tool
extends Node
class_name ScriptTools
## Script and file management tools for MCP.
## Handles: edit_script, validate_script, list_scripts,
## create_folder, delete_file, rename_file
var _editor_plugin: EditorPlugin = null
func set_editor_plugin(plugin: EditorPlugin) -> void:
_editor_plugin = plugin
func _refresh_filesystem() -> void:
if _editor_plugin:
_editor_plugin.get_editor_interface().get_resource_filesystem().scan()
func _ensure_res_path(path: String) -> String:
if not path.begins_with("res://"):
return "res://" + path
return path
# =============================================================================
# edit_script - Apply a small surgical code edit to a GDScript file
# =============================================================================
func edit_script(args: Dictionary) -> Dictionary:
var edit: Dictionary = args.get(&"edit", {})
if edit.is_empty():
return {&"ok": false, &"error": "Missing 'edit' payload"}
var path: String = str(edit.get(&"file", ""))
if path.is_empty():
return {&"ok": false, &"error": "Missing 'file' in edit"}
path = _ensure_res_path(path)
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
var spec_type: String = str(edit.get(&"type", "snippet_replace"))
if spec_type != "snippet_replace":
return {&"ok": false, &"error": "Only 'snippet_replace' type is supported"}
var old_snippet: String = str(edit.get(&"old_snippet", ""))
var new_snippet: String = str(edit.get(&"new_snippet", ""))
var context_before: String = str(edit.get(&"context_before", ""))
var context_after: String = str(edit.get(&"context_after", ""))
if old_snippet.is_empty():
return {&"ok": false, &"error": "Missing 'old_snippet' in edit"}
# Read current file content
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return {&"ok": false, &"error": "Cannot read file: " + path}
var content := file.get_as_text()
file.close()
# Find and replace the snippet
var search_text := old_snippet
var pos := content.find(search_text)
# If not found directly, try with context
if pos == -1 and not context_before.is_empty():
var ctx_pos := content.find(context_before)
if ctx_pos != -1:
var after_ctx := ctx_pos + context_before.length()
var remaining := content.substr(after_ctx)
var snippet_pos := remaining.find(old_snippet)
if snippet_pos != -1:
pos = after_ctx + snippet_pos
if pos == -1:
return {&"ok": false, &"error": "Could not find old_snippet in file. Make sure old_snippet matches the file content exactly."}
# Check for multiple occurrences
var second_pos := content.find(search_text, pos + 1)
if second_pos != -1 and context_before.is_empty() and context_after.is_empty():
return {&"ok": false, &"error": "old_snippet appears multiple times. Add context_before or context_after for disambiguation."}
# Apply the replacement
var original_content := content
var new_content := content.substr(0, pos) + new_snippet + content.substr(pos + old_snippet.length())
# Write back
file = FileAccess.open(path, FileAccess.WRITE)
if not file:
return {&"ok": false, &"error": "Cannot write file: " + path}
file.store_string(new_content)
file.close()
# Count changes
var old_lines := old_snippet.split("\n")
var new_lines := new_snippet.split("\n")
var added := maxi(0, new_lines.size() - old_lines.size())
var removed := maxi(0, old_lines.size() - new_lines.size())
_refresh_filesystem()
return {
&"ok": true,
&"path": path,
&"added": added,
&"removed": removed,
&"auto_applied": true,
&"message": "Applied edit to %s (+%d -%d lines)" % [path, added, removed]
}
# =============================================================================
# validate_script
# =============================================================================
func validate_script(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
path = _ensure_res_path(path)
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
# Read the source text directly from disk so we validate the *current*
# file contents, not a stale resource-cache entry.
var file := FileAccess.open(path, FileAccess.READ)
if not file:
return {&"ok": false, &"error": "Cannot read file: " + path}
var source_code := file.get_as_text()
file.close()
# Create a fresh GDScript instance and assign the source for parsing.
var script := GDScript.new()
script.source_code = source_code
# reload() triggers the parser/compiler and returns OK or an error code.
var err := script.reload()
if err != OK:
# Try to extract useful details from the Godot output log.
var errors := _collect_recent_script_errors(path)
return {
&"ok": true,
&"valid": false,
&"path": path,
&"error_code": err,
&"errors": errors,
&"message": "Script has errors." + (" Details: " + "; ".join(errors) if errors.size() > 0 else " Check Godot console for details.")
}
if not script.can_instantiate():
return {
&"ok": true,
&"valid": false,
&"path": path,
&"message": "Script parsed but cannot be instantiated (may have dependency errors)"
}
return {
&"ok": true,
&"valid": true,
&"path": path,
&"message": "No syntax errors found"
}
func _collect_recent_script_errors(script_path: String) -> Array:
"""Grab recent SCRIPT ERROR / Parse Error lines from the editor Output panel
that mention the given script path. Best-effort — returns [] if the panel
cannot be accessed."""
var errors: Array = []
if not _editor_plugin:
return errors
# Find the editor's Output panel RichTextLabel
var base := _editor_plugin.get_editor_interface().get_base_control()
var editor_log := _find_node_by_class(base, "EditorLog")
if not editor_log:
return errors
var rtl := _find_child_rtl(editor_log)
if not rtl:
return errors
var text: String = rtl.get_parsed_text()
var short_path := script_path.get_file() # e.g. "player.gd"
for line: String in text.split("\n"):
line = line.strip_edges()
if line.is_empty():
continue
if short_path in line or script_path in line:
if line.begins_with("SCRIPT ERROR:") or line.begins_with("Parse Error:") \
or line.begins_with("ERROR:") or line.begins_with("at:"):
errors.append(line)
# Keep only the last 10 relevant lines
if errors.size() > 10:
errors = errors.slice(errors.size() - 10)
return errors
func _find_node_by_class(root: Node, cls_name: String) -> Node:
if root.get_class() == cls_name:
return root
for child: Node in root.get_children():
var found := _find_node_by_class(child, cls_name)
if found:
return found
return null
func _find_child_rtl(node: Node) -> RichTextLabel:
for child: Node in node.get_children():
if child is RichTextLabel:
return child
var found := _find_child_rtl(child)
if found:
return found
return null
# =============================================================================
# list_scripts
# =============================================================================
func list_scripts(args: Dictionary) -> Dictionary:
var scripts: Array = []
_collect_scripts("res://", scripts)
return {
&"ok": true,
&"scripts": scripts,
&"count": scripts.size()
}
func _collect_scripts(path: String, out: Array) -> void:
var dir := DirAccess.open(path)
if dir == null:
return
dir.list_dir_begin()
var name := dir.get_next()
while name != "":
if name.begins_with("."):
name = dir.get_next()
continue
var full_path := path.path_join(name)
if dir.current_is_dir():
_collect_scripts(full_path, out)
elif name.ends_with(".gd"):
out.append(full_path)
name = dir.get_next()
dir.list_dir_end()
# =============================================================================
# create_folder
# =============================================================================
func create_folder(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
path = _ensure_res_path(path)
if DirAccess.dir_exists_absolute(path):
return {&"ok": true, &"path": path, &"message": "Directory already exists"}
var err := DirAccess.make_dir_recursive_absolute(path)
if err != OK:
return {&"ok": false, &"error": "Failed to create directory: " + str(err)}
_refresh_filesystem()
return {&"ok": true, &"path": path, &"message": "Directory created"}
# =============================================================================
# delete_file
# =============================================================================
func delete_file(args: Dictionary) -> Dictionary:
var path: String = str(args.get(&"path", ""))
var confirm: bool = bool(args.get(&"confirm", false))
var create_backup: bool = bool(args.get(&"create_backup", true))
if path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'path'"}
if not confirm:
return {&"ok": false, &"error": "Must set confirm=true to delete"}
path = _ensure_res_path(path)
if not FileAccess.file_exists(path):
return {&"ok": false, &"error": "File not found: " + path}
# Create backup
if create_backup:
var backup_path := path + ".bak"
DirAccess.copy_absolute(path, backup_path)
var err := DirAccess.remove_absolute(path)
if err != OK:
return {&"ok": false, &"error": "Failed to delete file: " + str(err)}
_refresh_filesystem()
return {&"ok": true, &"path": path, &"message": "File deleted" + (" (backup created)" if create_backup else "")}
# =============================================================================
# rename_file
# =============================================================================
func rename_file(args: Dictionary) -> Dictionary:
var old_path: String = str(args.get(&"old_path", ""))
var new_path: String = str(args.get(&"new_path", ""))
if old_path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'old_path'"}
if new_path.strip_edges().is_empty():
return {&"ok": false, &"error": "Missing 'new_path'"}
old_path = _ensure_res_path(old_path)
new_path = _ensure_res_path(new_path)
if not FileAccess.file_exists(old_path):
return {&"ok": false, &"error": "File not found: " + old_path}
if FileAccess.file_exists(new_path):
return {&"ok": false, &"error": "Target already exists: " + new_path}
# Ensure target directory exists
var dir_path := new_path.get_base_dir()
if not DirAccess.dir_exists_absolute(dir_path):
DirAccess.make_dir_recursive_absolute(dir_path)
var err := DirAccess.rename_absolute(old_path, new_path)
if err != OK:
return {&"ok": false, &"error": "Failed to rename: " + str(err)}
_refresh_filesystem()
return {&"ok": true, &"old_path": old_path, &"new_path": new_path,
&"message": "Renamed %s to %s" % [old_path, new_path]}