godotbridge 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- godotbridge-1.0.0/.gitignore +20 -0
- godotbridge-1.0.0/PKG-INFO +31 -0
- godotbridge-1.0.0/pyproject.toml +57 -0
- godotbridge-1.0.0/src/godot_mcp/__init__.py +0 -0
- godotbridge-1.0.0/src/godot_mcp/__main__.py +15 -0
- godotbridge-1.0.0/src/godot_mcp/bridge/__init__.py +4 -0
- godotbridge-1.0.0/src/godot_mcp/bridge/client.py +88 -0
- godotbridge-1.0.0/src/godot_mcp/bridge/game_process.py +61 -0
- godotbridge-1.0.0/src/godot_mcp/bridge/protocol.py +79 -0
- godotbridge-1.0.0/src/godot_mcp/server.py +49 -0
- godotbridge-1.0.0/src/godot_mcp/tools/__init__.py +0 -0
- godotbridge-1.0.0/src/godot_mcp/tools/_licensing.py +257 -0
- godotbridge-1.0.0/src/godot_mcp/tools/_pixellab.py +72 -0
- godotbridge-1.0.0/src/godot_mcp/tools/advanced_scripting.py +18 -0
- godotbridge-1.0.0/src/godot_mcp/tools/animation_systems.py +190 -0
- godotbridge-1.0.0/src/godot_mcp/tools/api_reference.py +87 -0
- godotbridge-1.0.0/src/godot_mcp/tools/asset_management.py +51 -0
- godotbridge-1.0.0/src/godot_mcp/tools/audio_configuration.py +78 -0
- godotbridge-1.0.0/src/godot_mcp/tools/build_export.py +19 -0
- godotbridge-1.0.0/src/godot_mcp/tools/editor_interaction.py +175 -0
- godotbridge-1.0.0/src/godot_mcp/tools/execution_testing.py +77 -0
- godotbridge-1.0.0/src/godot_mcp/tools/file_operations.py +58 -0
- godotbridge-1.0.0/src/godot_mcp/tools/game_templates.py +39 -0
- godotbridge-1.0.0/src/godot_mcp/tools/image_studio.py +324 -0
- godotbridge-1.0.0/src/godot_mcp/tools/input_setup.py +38 -0
- godotbridge-1.0.0/src/godot_mcp/tools/multiplayer_setup.py +39 -0
- godotbridge-1.0.0/src/godot_mcp/tools/navigation_systems.py +74 -0
- godotbridge-1.0.0/src/godot_mcp/tools/node_composition.py +50 -0
- godotbridge-1.0.0/src/godot_mcp/tools/particle_effects.py +71 -0
- godotbridge-1.0.0/src/godot_mcp/tools/physics_configuration.py +120 -0
- godotbridge-1.0.0/src/godot_mcp/tools/plugin_management.py +127 -0
- godotbridge-1.0.0/src/godot_mcp/tools/project_configuration.py +48 -0
- godotbridge-1.0.0/src/godot_mcp/tools/project_health.py +36 -0
- godotbridge-1.0.0/src/godot_mcp/tools/project_management.py +20 -0
- godotbridge-1.0.0/src/godot_mcp/tools/project_statistics.py +20 -0
- godotbridge-1.0.0/src/godot_mcp/tools/registry.py +64 -0
- godotbridge-1.0.0/src/godot_mcp/tools/resource_authoring.py +85 -0
- godotbridge-1.0.0/src/godot_mcp/tools/rigging.py +160 -0
- godotbridge-1.0.0/src/godot_mcp/tools/runtime_monitoring.py +296 -0
- godotbridge-1.0.0/src/godot_mcp/tools/scene_2d.py +123 -0
- godotbridge-1.0.0/src/godot_mcp/tools/scene_3d.py +224 -0
- godotbridge-1.0.0/src/godot_mcp/tools/scene_analysis.py +20 -0
- godotbridge-1.0.0/src/godot_mcp/tools/scene_construction.py +167 -0
- godotbridge-1.0.0/src/godot_mcp/tools/script_management.py +43 -0
- godotbridge-1.0.0/src/godot_mcp/tools/shader_authoring.py +100 -0
- godotbridge-1.0.0/src/godot_mcp/tools/signals_groups.py +51 -0
- godotbridge-1.0.0/src/godot_mcp/tools/theming.py +89 -0
- godotbridge-1.0.0/src/godot_mcp/tools/tilemap_editing.py +152 -0
- godotbridge-1.0.0/src/godot_mcp/tools/ui_layout.py +152 -0
- godotbridge-1.0.0/src/godot_mcp/tools/world_design.py +87 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/bridge_server.gd +224 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/advanced_scripting.gd +36 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/animation_systems.gd +445 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/api_reference.gd +53 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/asset_management.gd +139 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/audio_configuration.gd +161 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/build_export.gd +82 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/editor_interaction.gd +204 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/execution_testing.gd +124 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/file_operations.gd +149 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/game_templates.gd +172 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/image_studio.gd +724 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/input_setup.gd +119 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/multiplayer_setup.gd +82 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/navigation_systems.gd +129 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/node_composition.gd +69 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/particle_effects.gd +186 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/physics_configuration.gd +277 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/plugin_management.gd +109 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_configuration.gd +87 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_health.gd +114 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_management.gd +51 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_statistics.gd +88 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/resource_authoring.gd +271 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/rigging.gd +330 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_2d.gd +219 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_3d.gd +538 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_analysis.gd +45 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_construction.gd +455 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/script_management.gd +129 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/shader_authoring.gd +330 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/signals_groups.gd +135 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/theming.gd +245 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/tilemap_editing.gd +294 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/ui_layout.gd +326 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/world_design.gd +295 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/plugin.cfg +7 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/plugin.gd +29 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/runtime/mcp_runtime_probe.gd +636 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/dependency_graph.gd +84 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/fs_walk.gd +34 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/json_rpc.gd +27 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/node_resolver.gd +57 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/default_bus_layout.tres +13 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/main.tscn +6 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/project.godot +23 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/strings.en.translation +0 -0
- godotbridge-1.0.0/tests/fixtures/sample_project/strings.tr.translation +0 -0
- godotbridge-1.0.0/tests/unit/test_protocol.py +72 -0
- godotbridge-1.0.0/tests/unit/test_registry.py +34 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.venv/
|
|
5
|
+
*.egg-info/
|
|
6
|
+
.pytest_cache/
|
|
7
|
+
|
|
8
|
+
# Godot
|
|
9
|
+
.godot/
|
|
10
|
+
*.import
|
|
11
|
+
export_presets.cfg
|
|
12
|
+
/.claude
|
|
13
|
+
|
|
14
|
+
# scripts/test_tools.py fixture output — a fresh, uniquely-named directory
|
|
15
|
+
# every run (see the script's own comments for why), never meant to be committed
|
|
16
|
+
test/_test_tmp*
|
|
17
|
+
|
|
18
|
+
# Web backend
|
|
19
|
+
web/app/config.local.php
|
|
20
|
+
web/vendor/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: godotbridge
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server that lets AI agents (Claude, Cursor, Windsurf) inspect and control a Godot 4.x editor and running game. 345 tools across 36 categories.
|
|
5
|
+
Project-URL: Homepage, https://godotbridge.dev
|
|
6
|
+
Project-URL: Documentation, https://godotbridge.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/godotbridge/releases
|
|
8
|
+
Project-URL: Issues, https://github.com/godotbridge/releases/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/godotbridge/releases/releases
|
|
10
|
+
Author-email: DigitalFusion <info@digital-fusion.com.tr>
|
|
11
|
+
License: Proprietary
|
|
12
|
+
Keywords: ai,claude,cursor,gamedev,godot,mcp,windsurf
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: Other/Proprietary License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Games/Entertainment
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Requires-Dist: httpx>=0.27
|
|
26
|
+
Requires-Dist: mcp>=1.2.0
|
|
27
|
+
Requires-Dist: pyyaml>=6.0
|
|
28
|
+
Requires-Dist: websockets>=12.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "godotbridge"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "MCP server that lets AI agents (Claude, Cursor, Windsurf) inspect and control a Godot 4.x editor and running game. 345 tools across 36 categories."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = {text = "Proprietary"}
|
|
7
|
+
authors = [
|
|
8
|
+
{name = "DigitalFusion", email = "info@digital-fusion.com.tr"},
|
|
9
|
+
]
|
|
10
|
+
keywords = ["godot", "mcp", "ai", "gamedev", "claude", "cursor", "windsurf"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 5 - Production/Stable",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"License :: Other/Proprietary License",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Games/Entertainment",
|
|
22
|
+
"Topic :: Software Development :: Libraries",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"mcp>=1.2.0",
|
|
26
|
+
"websockets>=12.0",
|
|
27
|
+
"pyyaml>=6.0",
|
|
28
|
+
"httpx>=0.27",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=8.0",
|
|
34
|
+
"pytest-asyncio>=0.24",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://godotbridge.dev"
|
|
39
|
+
Documentation = "https://godotbridge.dev/docs"
|
|
40
|
+
Repository = "https://github.com/godotbridge/releases"
|
|
41
|
+
Issues = "https://github.com/godotbridge/releases/issues"
|
|
42
|
+
Changelog = "https://github.com/godotbridge/releases/releases"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
godotbridge = "godot_mcp.__main__:main"
|
|
46
|
+
godot-mcp = "godot_mcp.__main__:main"
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/godot_mcp"]
|
|
54
|
+
|
|
55
|
+
[tool.pytest.ini_options]
|
|
56
|
+
testpaths = ["tests"]
|
|
57
|
+
asyncio_mode = "auto"
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from .server import build_server
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
logging.basicConfig(level=logging.INFO)
|
|
10
|
+
mcp = build_server()
|
|
11
|
+
mcp.run(transport="stdio")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""WebSocket client for the editor-channel bridge hosted by the Godot addon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
9
|
+
from websockets.exceptions import ConnectionClosed
|
|
10
|
+
|
|
11
|
+
from .protocol import BridgeError, RequestCorrelator
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("godot_mcp.bridge")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class BridgeClient:
|
|
17
|
+
"""Auto-reconnecting client for ws://host:port (the editor channel).
|
|
18
|
+
|
|
19
|
+
The Godot editor addon is the long-lived side and hosts the server; this
|
|
20
|
+
client is spawned/torn down per MCP session, so it reconnects with backoff
|
|
21
|
+
whenever the editor isn't up yet or restarts mid-session.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, host: str = "127.0.0.1", port: int = 8765) -> None:
|
|
25
|
+
self._uri = f"ws://{host}:{port}"
|
|
26
|
+
self._correlator = RequestCorrelator()
|
|
27
|
+
self._connection: ClientConnection | None = None
|
|
28
|
+
self._recv_task: asyncio.Task | None = None
|
|
29
|
+
self._connect_lock = asyncio.Lock()
|
|
30
|
+
|
|
31
|
+
async def call(self, tool: str, params: dict | None = None, timeout: float | None = None) -> dict:
|
|
32
|
+
await self._ensure_connected()
|
|
33
|
+
request_id, payload = self._correlator.encode_request(tool, params or {})
|
|
34
|
+
assert self._connection is not None
|
|
35
|
+
try:
|
|
36
|
+
await self._connection.send(payload)
|
|
37
|
+
except ConnectionClosed as exc:
|
|
38
|
+
raise BridgeError("NOT_CONNECTED", "Lost connection to Godot editor bridge") from exc
|
|
39
|
+
return await self._correlator.wait_for(request_id, timeout)
|
|
40
|
+
|
|
41
|
+
async def close(self) -> None:
|
|
42
|
+
if self._recv_task is not None:
|
|
43
|
+
self._recv_task.cancel()
|
|
44
|
+
self._recv_task = None
|
|
45
|
+
if self._connection is not None:
|
|
46
|
+
await self._connection.close()
|
|
47
|
+
self._connection = None
|
|
48
|
+
|
|
49
|
+
async def _ensure_connected(self) -> None:
|
|
50
|
+
if self._connection is not None:
|
|
51
|
+
return
|
|
52
|
+
async with self._connect_lock:
|
|
53
|
+
if self._connection is not None:
|
|
54
|
+
return
|
|
55
|
+
try:
|
|
56
|
+
self._connection = await connect(self._uri, open_timeout=5)
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
raise BridgeError(
|
|
59
|
+
"NOT_CONNECTED",
|
|
60
|
+
f"Could not reach Godot editor bridge at {self._uri}. Is the project open with the addon enabled?",
|
|
61
|
+
) from exc
|
|
62
|
+
self._recv_task = asyncio.create_task(self._recv_loop())
|
|
63
|
+
|
|
64
|
+
async def _recv_loop(self) -> None:
|
|
65
|
+
assert self._connection is not None
|
|
66
|
+
try:
|
|
67
|
+
async for raw in self._connection:
|
|
68
|
+
self._correlator.resolve(raw)
|
|
69
|
+
except ConnectionClosed:
|
|
70
|
+
pass
|
|
71
|
+
finally:
|
|
72
|
+
self._correlator.fail_all("NOT_CONNECTED", "Connection to Godot editor bridge closed")
|
|
73
|
+
self._connection = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
_default_client: BridgeClient | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_default_client(client: BridgeClient) -> None:
|
|
80
|
+
"""Registers the process-wide bridge client used by the tools/* modules."""
|
|
81
|
+
global _default_client
|
|
82
|
+
_default_client = client
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def get_default_client() -> BridgeClient:
|
|
86
|
+
if _default_client is None:
|
|
87
|
+
raise RuntimeError("Bridge client not initialized — call set_default_client() during server startup")
|
|
88
|
+
return _default_client
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Manages a Godot game process launched directly by this server, bypassing
|
|
2
|
+
EditorInterface.play_custom_scene(). The editor-Play path spawns the game as
|
|
3
|
+
a child of the *editor* process, and its stdout/stderr never reaches
|
|
4
|
+
GDScript — confirmed empirically (see the project's EditorDebuggerPlugin
|
|
5
|
+
capture experiment, which received zero messages from a played session).
|
|
6
|
+
Launching the game as our own subprocess sidesteps that entirely: we own the
|
|
7
|
+
pipes directly. The runtime probe autoload still connects back over the
|
|
8
|
+
runtime channel as normal either way, so every other game_* tool keeps
|
|
9
|
+
working unchanged against an instance launched this way.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from collections import deque
|
|
16
|
+
|
|
17
|
+
_MAX_BUFFERED_LINES = 2000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GameProcess:
|
|
21
|
+
def __init__(self, process: asyncio.subprocess.Process) -> None:
|
|
22
|
+
self.process = process
|
|
23
|
+
self.stdout_lines: deque[str] = deque(maxlen=_MAX_BUFFERED_LINES)
|
|
24
|
+
self.stderr_lines: deque[str] = deque(maxlen=_MAX_BUFFERED_LINES)
|
|
25
|
+
assert process.stdout is not None and process.stderr is not None
|
|
26
|
+
self._stdout_task = asyncio.create_task(self._pump(process.stdout, self.stdout_lines))
|
|
27
|
+
self._stderr_task = asyncio.create_task(self._pump(process.stderr, self.stderr_lines))
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
async def _pump(stream: asyncio.StreamReader, buffer: deque[str]) -> None:
|
|
31
|
+
while True:
|
|
32
|
+
line = await stream.readline()
|
|
33
|
+
if not line:
|
|
34
|
+
return
|
|
35
|
+
buffer.append(line.decode(errors="replace").rstrip("\r\n"))
|
|
36
|
+
|
|
37
|
+
def is_running(self) -> bool:
|
|
38
|
+
return self.process.returncode is None
|
|
39
|
+
|
|
40
|
+
async def stop(self) -> None:
|
|
41
|
+
if self.is_running():
|
|
42
|
+
self.process.terminate()
|
|
43
|
+
try:
|
|
44
|
+
await asyncio.wait_for(self.process.wait(), timeout=5)
|
|
45
|
+
except asyncio.TimeoutError:
|
|
46
|
+
self.process.kill()
|
|
47
|
+
await self.process.wait()
|
|
48
|
+
self._stdout_task.cancel()
|
|
49
|
+
self._stderr_task.cancel()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_current: GameProcess | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_current() -> GameProcess | None:
|
|
56
|
+
return _current
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def set_current(proc: GameProcess | None) -> None:
|
|
60
|
+
global _current
|
|
61
|
+
_current = proc
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Wire framing and request/response correlation for the editor-channel bridge.
|
|
2
|
+
|
|
3
|
+
Matches protocol/MESSAGES.md: one JSON object per WebSocket text frame,
|
|
4
|
+
`{id, tool, params}` requests answered by `{id, ok, result | error}` responses.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import uuid
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BridgeError(Exception):
|
|
16
|
+
"""Raised when a tool call fails, times out, or the bridge isn't connected."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, code: str, message: str) -> None:
|
|
19
|
+
super().__init__(f"{code}: {message}")
|
|
20
|
+
self.code = code
|
|
21
|
+
self.message = message
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class _PendingCall:
|
|
26
|
+
future: asyncio.Future
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RequestCorrelator:
|
|
30
|
+
"""Tracks in-flight requests by id and resolves them as responses arrive."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, default_timeout: float = 10.0) -> None:
|
|
33
|
+
self._default_timeout = default_timeout
|
|
34
|
+
self._pending: dict[str, _PendingCall] = {}
|
|
35
|
+
|
|
36
|
+
def encode_request(self, tool: str, params: dict) -> tuple[str, str]:
|
|
37
|
+
request_id = str(uuid.uuid4())
|
|
38
|
+
payload = json.dumps({"id": request_id, "tool": tool, "params": params})
|
|
39
|
+
return request_id, payload
|
|
40
|
+
|
|
41
|
+
async def wait_for(self, request_id: str, timeout: float | None = None) -> dict:
|
|
42
|
+
loop = asyncio.get_running_loop()
|
|
43
|
+
future: asyncio.Future = loop.create_future()
|
|
44
|
+
self._pending[request_id] = _PendingCall(future=future)
|
|
45
|
+
try:
|
|
46
|
+
return await asyncio.wait_for(future, timeout or self._default_timeout)
|
|
47
|
+
except asyncio.TimeoutError as exc:
|
|
48
|
+
raise BridgeError("TIMEOUT", f"No response for '{request_id}' within timeout") from exc
|
|
49
|
+
finally:
|
|
50
|
+
self._pending.pop(request_id, None)
|
|
51
|
+
|
|
52
|
+
def resolve(self, raw_frame: str) -> None:
|
|
53
|
+
"""Feed one received frame in; resolves the matching pending call, if any."""
|
|
54
|
+
try:
|
|
55
|
+
message = json.loads(raw_frame)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
request_id = message.get("id")
|
|
60
|
+
if request_id is None or request_id not in self._pending:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
pending = self._pending[request_id]
|
|
64
|
+
if pending.future.done():
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
if message.get("ok"):
|
|
68
|
+
pending.future.set_result(message.get("result"))
|
|
69
|
+
else:
|
|
70
|
+
error = message.get("error", {})
|
|
71
|
+
pending.future.set_exception(
|
|
72
|
+
BridgeError(error.get("code", "UNKNOWN"), error.get("message", "no message"))
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def fail_all(self, code: str, message: str) -> None:
|
|
76
|
+
"""Fail every in-flight call, e.g. when the connection drops."""
|
|
77
|
+
for pending in list(self._pending.values()):
|
|
78
|
+
if not pending.future.done():
|
|
79
|
+
pending.future.set_exception(BridgeError(code, message))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Builds the FastMCP app and registers every tool from the manifest-driven registry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from .bridge.client import BridgeClient, set_default_client
|
|
11
|
+
from .tools._licensing import LITE_CATEGORIES, is_tool_available, ensure_env_activation
|
|
12
|
+
from .tools.registry import load_registry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("godot_mcp.server")
|
|
15
|
+
|
|
16
|
+
_UPGRADE_MSG = (
|
|
17
|
+
"This tool requires a GodotBridge Full license. "
|
|
18
|
+
"Call activate_license with your key, or visit https://godotbridge.dev to purchase one."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _gated(handler, category: str):
|
|
23
|
+
"""Wrap a tool handler so it checks the license at call time."""
|
|
24
|
+
|
|
25
|
+
@functools.wraps(handler)
|
|
26
|
+
async def wrapper(*args, **kwargs):
|
|
27
|
+
await ensure_env_activation()
|
|
28
|
+
if not is_tool_available(category):
|
|
29
|
+
return {"__error_code__": "UPGRADE_REQUIRED", "__error_message__": _UPGRADE_MSG}
|
|
30
|
+
return await handler(*args, **kwargs)
|
|
31
|
+
|
|
32
|
+
return wrapper
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_server(host: str = "127.0.0.1", port: int = 8765) -> FastMCP:
|
|
36
|
+
set_default_client(BridgeClient(host=host, port=port))
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP("godot-mcp")
|
|
39
|
+
for spec in load_registry():
|
|
40
|
+
is_lite = spec.category in LITE_CATEGORIES
|
|
41
|
+
handler = spec.handler if is_lite else _gated(spec.handler, spec.category)
|
|
42
|
+
desc = spec.description or ""
|
|
43
|
+
if not is_lite:
|
|
44
|
+
desc = "[Full] " + desc
|
|
45
|
+
mcp.add_tool(handler, name=spec.name, description=desc or None)
|
|
46
|
+
logger.debug("Registered tool %s (phase %d, category %s, tier %s)",
|
|
47
|
+
spec.name, spec.phase, spec.category, "lite" if is_lite else "full")
|
|
48
|
+
|
|
49
|
+
return mcp
|
|
File without changes
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Freemium licensing — Lite (free) / Full (paid) tier gating.
|
|
2
|
+
|
|
3
|
+
Validates license keys against the LicenseManager API at
|
|
4
|
+
``https://license.digital-fusion.com.tr``. After a successful online
|
|
5
|
+
activation the response (including the offline-until window) is cached
|
|
6
|
+
in memory so the server keeps working without internet until that
|
|
7
|
+
window expires.
|
|
8
|
+
|
|
9
|
+
The active license is held in memory only, never persisted to disk.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_BASE_URL = "https://license.digital-fusion.com.tr"
|
|
26
|
+
_APPLICATION = "GodotBridge"
|
|
27
|
+
_TIMEOUT = 15.0
|
|
28
|
+
|
|
29
|
+
LITE_CATEGORIES: frozenset[str] = frozenset({
|
|
30
|
+
"api_reference",
|
|
31
|
+
"editor_interaction",
|
|
32
|
+
"file_operations",
|
|
33
|
+
"input_setup",
|
|
34
|
+
"node_composition",
|
|
35
|
+
"project_configuration",
|
|
36
|
+
"project_health",
|
|
37
|
+
"project_management",
|
|
38
|
+
"project_statistics",
|
|
39
|
+
"scene_analysis",
|
|
40
|
+
"script_management",
|
|
41
|
+
"signals_groups",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
_license: dict | None = None
|
|
45
|
+
_license_key: str = ""
|
|
46
|
+
_fingerprint: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Machine fingerprint
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _machine_fingerprint() -> str:
|
|
54
|
+
global _fingerprint
|
|
55
|
+
if _fingerprint is not None:
|
|
56
|
+
return _fingerprint
|
|
57
|
+
raw = f"{platform.node()}|{platform.system()}|{platform.machine()}|{uuid.getnode()}"
|
|
58
|
+
_fingerprint = hashlib.sha256(raw.encode()).hexdigest()
|
|
59
|
+
return _fingerprint
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# LicenseManager API calls
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
async def _api_activate(key: str) -> dict:
|
|
67
|
+
fp = _machine_fingerprint()
|
|
68
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
69
|
+
resp = await client.post(
|
|
70
|
+
f"{_BASE_URL}/api/v2/license/activate",
|
|
71
|
+
json={
|
|
72
|
+
"LicenseKey": key,
|
|
73
|
+
"Application": _APPLICATION,
|
|
74
|
+
"FingerprintHash": fp,
|
|
75
|
+
"MachineName": platform.node(),
|
|
76
|
+
"OsName": f"{platform.system()} {platform.release()}",
|
|
77
|
+
"ActivationType": "AppDevice",
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
return resp.json()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _api_validate(key: str) -> dict:
|
|
85
|
+
fp = _machine_fingerprint()
|
|
86
|
+
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
87
|
+
resp = await client.post(
|
|
88
|
+
f"{_BASE_URL}/api/v2/license/validate",
|
|
89
|
+
json={
|
|
90
|
+
"LicenseKey": key,
|
|
91
|
+
"Application": _APPLICATION,
|
|
92
|
+
"FingerprintHash": fp,
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
return resp.json()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Response parsing helpers
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
_CODE_MAP = {
|
|
104
|
+
"InvalidKey": ("INVALID_LICENSE", "License key not found."),
|
|
105
|
+
"Expired": ("LICENSE_EXPIRED", "License has expired."),
|
|
106
|
+
"Revoked": ("LICENSE_REVOKED", "License has been revoked."),
|
|
107
|
+
"Suspended": ("LICENSE_SUSPENDED", "License has been suspended."),
|
|
108
|
+
"ActivationLimitReached": ("ACTIVATION_LIMIT", "Maximum activations reached. Deactivate another device first."),
|
|
109
|
+
"PendingPayment": ("PENDING_PAYMENT", "License is pending payment confirmation."),
|
|
110
|
+
"Cancelled": ("LICENSE_CANCELLED", "License has been cancelled."),
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_response(api_resp: dict) -> dict:
|
|
115
|
+
"""Convert a LicenseManager API response into our internal format."""
|
|
116
|
+
success = api_resp.get("Success", False)
|
|
117
|
+
code = api_resp.get("Code", "")
|
|
118
|
+
data = api_resp.get("Data") or {}
|
|
119
|
+
|
|
120
|
+
if not success:
|
|
121
|
+
mapped = _CODE_MAP.get(code, ("LICENSE_ERROR", api_resp.get("Message", "License validation failed.")))
|
|
122
|
+
return {"__error_code__": mapped[0], "__error_message__": mapped[1]}
|
|
123
|
+
|
|
124
|
+
can_run = data.get("CanRun", False)
|
|
125
|
+
if not can_run:
|
|
126
|
+
status = data.get("Status", code)
|
|
127
|
+
mapped = _CODE_MAP.get(status, ("LICENSE_ERROR", f"License cannot run: {status}"))
|
|
128
|
+
return {"__error_code__": mapped[0], "__error_message__": mapped[1]}
|
|
129
|
+
|
|
130
|
+
license_info = data.get("License") or {}
|
|
131
|
+
return {
|
|
132
|
+
"activated": True,
|
|
133
|
+
"tier": "full",
|
|
134
|
+
"email": license_info.get("CustomerEmail", ""),
|
|
135
|
+
"expires": license_info.get("ExpiresAt", "never"),
|
|
136
|
+
"license_type": license_info.get("LicenseType", ""),
|
|
137
|
+
"status": data.get("Status", "Active"),
|
|
138
|
+
"offline_until": data.get("OfflineUntil", ""),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
# Public API
|
|
144
|
+
# ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
async def activate(key: str) -> dict:
|
|
147
|
+
"""Activate a license key via the LicenseManager API."""
|
|
148
|
+
global _license, _license_key
|
|
149
|
+
|
|
150
|
+
key = key.strip()
|
|
151
|
+
if not key:
|
|
152
|
+
return {"__error_code__": "INVALID_PARAMS", "__error_message__": "license_key must not be empty"}
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
api_resp = await _api_activate(key)
|
|
156
|
+
except httpx.HTTPStatusError as exc:
|
|
157
|
+
return {
|
|
158
|
+
"__error_code__": "LICENSE_SERVER_ERROR",
|
|
159
|
+
"__error_message__": "License server returned HTTP %d" % exc.response.status_code,
|
|
160
|
+
}
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
log.exception("License activation failed")
|
|
163
|
+
if _license is not None and _is_offline_valid():
|
|
164
|
+
return {**_license, "note": "Offline mode — using cached license."}
|
|
165
|
+
return {
|
|
166
|
+
"__error_code__": "LICENSE_SERVER_UNREACHABLE",
|
|
167
|
+
"__error_message__": "Could not reach license server: %s" % str(exc)[:200],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result = _parse_response(api_resp)
|
|
171
|
+
if result.get("activated"):
|
|
172
|
+
_license = result
|
|
173
|
+
_license_key = key
|
|
174
|
+
return result
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def validate() -> dict:
|
|
178
|
+
"""Re-validate the current license with the server."""
|
|
179
|
+
if not _license_key:
|
|
180
|
+
return {"__error_code__": "NO_LICENSE", "__error_message__": "No license activated."}
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
api_resp = await _api_validate(_license_key)
|
|
184
|
+
except Exception:
|
|
185
|
+
if _license is not None and _is_offline_valid():
|
|
186
|
+
return {**_license, "note": "Offline mode — using cached license."}
|
|
187
|
+
return {"__error_code__": "LICENSE_SERVER_UNREACHABLE", "__error_message__": "Could not reach license server."}
|
|
188
|
+
|
|
189
|
+
result = _parse_response(api_resp)
|
|
190
|
+
if result.get("activated"):
|
|
191
|
+
global _license
|
|
192
|
+
_license = result
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def deactivate() -> None:
|
|
197
|
+
global _license, _license_key
|
|
198
|
+
_license = None
|
|
199
|
+
_license_key = ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_offline_valid() -> bool:
|
|
203
|
+
if _license is None:
|
|
204
|
+
return False
|
|
205
|
+
offline_until = _license.get("offline_until", "")
|
|
206
|
+
if not offline_until:
|
|
207
|
+
return False
|
|
208
|
+
try:
|
|
209
|
+
deadline = datetime.fromisoformat(offline_until.replace("Z", "+00:00"))
|
|
210
|
+
return datetime.now(timezone.utc) < deadline
|
|
211
|
+
except (ValueError, TypeError):
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_tier() -> str:
|
|
216
|
+
if _license is not None and _license.get("tier") == "full":
|
|
217
|
+
if _is_offline_valid() or _license.get("activated"):
|
|
218
|
+
return "full"
|
|
219
|
+
return "lite"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def get_info() -> dict:
|
|
223
|
+
tier = get_tier()
|
|
224
|
+
if _license is not None:
|
|
225
|
+
return {
|
|
226
|
+
"tier": tier,
|
|
227
|
+
"email": _license.get("email", ""),
|
|
228
|
+
"expires": _license.get("expires", ""),
|
|
229
|
+
"status": _license.get("status", ""),
|
|
230
|
+
"offline_until": _license.get("offline_until", ""),
|
|
231
|
+
}
|
|
232
|
+
return {"tier": "lite", "email": "", "expires": "", "status": "", "offline_until": ""}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def is_tool_available(category: str) -> bool:
|
|
236
|
+
if category in LITE_CATEGORIES:
|
|
237
|
+
return True
|
|
238
|
+
return get_tier() == "full"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_env_checked: bool = False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def ensure_env_activation() -> None:
|
|
245
|
+
"""Auto-activate from GODOT_MCP_LICENSE_KEY on first call, then no-op."""
|
|
246
|
+
global _env_checked
|
|
247
|
+
if _env_checked:
|
|
248
|
+
return
|
|
249
|
+
_env_checked = True
|
|
250
|
+
key = os.environ.get("GODOT_MCP_LICENSE_KEY", "").strip()
|
|
251
|
+
if not key:
|
|
252
|
+
return
|
|
253
|
+
result = await activate(key)
|
|
254
|
+
if result.get("activated"):
|
|
255
|
+
log.info("License auto-activated from GODOT_MCP_LICENSE_KEY (tier: full)")
|
|
256
|
+
else:
|
|
257
|
+
log.warning("GODOT_MCP_LICENSE_KEY activation failed: %s", result.get("__error_message__", "unknown"))
|