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.
Files changed (100) hide show
  1. godotbridge-1.0.0/.gitignore +20 -0
  2. godotbridge-1.0.0/PKG-INFO +31 -0
  3. godotbridge-1.0.0/pyproject.toml +57 -0
  4. godotbridge-1.0.0/src/godot_mcp/__init__.py +0 -0
  5. godotbridge-1.0.0/src/godot_mcp/__main__.py +15 -0
  6. godotbridge-1.0.0/src/godot_mcp/bridge/__init__.py +4 -0
  7. godotbridge-1.0.0/src/godot_mcp/bridge/client.py +88 -0
  8. godotbridge-1.0.0/src/godot_mcp/bridge/game_process.py +61 -0
  9. godotbridge-1.0.0/src/godot_mcp/bridge/protocol.py +79 -0
  10. godotbridge-1.0.0/src/godot_mcp/server.py +49 -0
  11. godotbridge-1.0.0/src/godot_mcp/tools/__init__.py +0 -0
  12. godotbridge-1.0.0/src/godot_mcp/tools/_licensing.py +257 -0
  13. godotbridge-1.0.0/src/godot_mcp/tools/_pixellab.py +72 -0
  14. godotbridge-1.0.0/src/godot_mcp/tools/advanced_scripting.py +18 -0
  15. godotbridge-1.0.0/src/godot_mcp/tools/animation_systems.py +190 -0
  16. godotbridge-1.0.0/src/godot_mcp/tools/api_reference.py +87 -0
  17. godotbridge-1.0.0/src/godot_mcp/tools/asset_management.py +51 -0
  18. godotbridge-1.0.0/src/godot_mcp/tools/audio_configuration.py +78 -0
  19. godotbridge-1.0.0/src/godot_mcp/tools/build_export.py +19 -0
  20. godotbridge-1.0.0/src/godot_mcp/tools/editor_interaction.py +175 -0
  21. godotbridge-1.0.0/src/godot_mcp/tools/execution_testing.py +77 -0
  22. godotbridge-1.0.0/src/godot_mcp/tools/file_operations.py +58 -0
  23. godotbridge-1.0.0/src/godot_mcp/tools/game_templates.py +39 -0
  24. godotbridge-1.0.0/src/godot_mcp/tools/image_studio.py +324 -0
  25. godotbridge-1.0.0/src/godot_mcp/tools/input_setup.py +38 -0
  26. godotbridge-1.0.0/src/godot_mcp/tools/multiplayer_setup.py +39 -0
  27. godotbridge-1.0.0/src/godot_mcp/tools/navigation_systems.py +74 -0
  28. godotbridge-1.0.0/src/godot_mcp/tools/node_composition.py +50 -0
  29. godotbridge-1.0.0/src/godot_mcp/tools/particle_effects.py +71 -0
  30. godotbridge-1.0.0/src/godot_mcp/tools/physics_configuration.py +120 -0
  31. godotbridge-1.0.0/src/godot_mcp/tools/plugin_management.py +127 -0
  32. godotbridge-1.0.0/src/godot_mcp/tools/project_configuration.py +48 -0
  33. godotbridge-1.0.0/src/godot_mcp/tools/project_health.py +36 -0
  34. godotbridge-1.0.0/src/godot_mcp/tools/project_management.py +20 -0
  35. godotbridge-1.0.0/src/godot_mcp/tools/project_statistics.py +20 -0
  36. godotbridge-1.0.0/src/godot_mcp/tools/registry.py +64 -0
  37. godotbridge-1.0.0/src/godot_mcp/tools/resource_authoring.py +85 -0
  38. godotbridge-1.0.0/src/godot_mcp/tools/rigging.py +160 -0
  39. godotbridge-1.0.0/src/godot_mcp/tools/runtime_monitoring.py +296 -0
  40. godotbridge-1.0.0/src/godot_mcp/tools/scene_2d.py +123 -0
  41. godotbridge-1.0.0/src/godot_mcp/tools/scene_3d.py +224 -0
  42. godotbridge-1.0.0/src/godot_mcp/tools/scene_analysis.py +20 -0
  43. godotbridge-1.0.0/src/godot_mcp/tools/scene_construction.py +167 -0
  44. godotbridge-1.0.0/src/godot_mcp/tools/script_management.py +43 -0
  45. godotbridge-1.0.0/src/godot_mcp/tools/shader_authoring.py +100 -0
  46. godotbridge-1.0.0/src/godot_mcp/tools/signals_groups.py +51 -0
  47. godotbridge-1.0.0/src/godot_mcp/tools/theming.py +89 -0
  48. godotbridge-1.0.0/src/godot_mcp/tools/tilemap_editing.py +152 -0
  49. godotbridge-1.0.0/src/godot_mcp/tools/ui_layout.py +152 -0
  50. godotbridge-1.0.0/src/godot_mcp/tools/world_design.py +87 -0
  51. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/bridge_server.gd +224 -0
  52. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/advanced_scripting.gd +36 -0
  53. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/animation_systems.gd +445 -0
  54. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/api_reference.gd +53 -0
  55. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/asset_management.gd +139 -0
  56. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/audio_configuration.gd +161 -0
  57. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/build_export.gd +82 -0
  58. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/editor_interaction.gd +204 -0
  59. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/execution_testing.gd +124 -0
  60. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/file_operations.gd +149 -0
  61. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/game_templates.gd +172 -0
  62. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/image_studio.gd +724 -0
  63. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/input_setup.gd +119 -0
  64. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/multiplayer_setup.gd +82 -0
  65. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/navigation_systems.gd +129 -0
  66. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/node_composition.gd +69 -0
  67. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/particle_effects.gd +186 -0
  68. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/physics_configuration.gd +277 -0
  69. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/plugin_management.gd +109 -0
  70. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_configuration.gd +87 -0
  71. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_health.gd +114 -0
  72. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_management.gd +51 -0
  73. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/project_statistics.gd +88 -0
  74. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/resource_authoring.gd +271 -0
  75. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/rigging.gd +330 -0
  76. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_2d.gd +219 -0
  77. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_3d.gd +538 -0
  78. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_analysis.gd +45 -0
  79. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/scene_construction.gd +455 -0
  80. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/script_management.gd +129 -0
  81. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/shader_authoring.gd +330 -0
  82. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/signals_groups.gd +135 -0
  83. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/theming.gd +245 -0
  84. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/tilemap_editing.gd +294 -0
  85. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/ui_layout.gd +326 -0
  86. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/handlers/world_design.gd +295 -0
  87. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/plugin.cfg +7 -0
  88. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/plugin.gd +29 -0
  89. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/runtime/mcp_runtime_probe.gd +636 -0
  90. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/dependency_graph.gd +84 -0
  91. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/fs_walk.gd +34 -0
  92. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/json_rpc.gd +27 -0
  93. godotbridge-1.0.0/tests/fixtures/sample_project/addons/godot_mcp_bridge/utils/node_resolver.gd +57 -0
  94. godotbridge-1.0.0/tests/fixtures/sample_project/default_bus_layout.tres +13 -0
  95. godotbridge-1.0.0/tests/fixtures/sample_project/main.tscn +6 -0
  96. godotbridge-1.0.0/tests/fixtures/sample_project/project.godot +23 -0
  97. godotbridge-1.0.0/tests/fixtures/sample_project/strings.en.translation +0 -0
  98. godotbridge-1.0.0/tests/fixtures/sample_project/strings.tr.translation +0 -0
  99. godotbridge-1.0.0/tests/unit/test_protocol.py +72 -0
  100. 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,4 @@
1
+ from .client import BridgeClient, get_default_client, set_default_client
2
+ from .protocol import BridgeError
3
+
4
+ __all__ = ["BridgeClient", "BridgeError", "get_default_client", "set_default_client"]
@@ -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"))