engine-mcp 0.1.0__py3-none-any.whl
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.
- engine_mcp/__init__.py +3 -0
- engine_mcp/godot_connect.py +279 -0
- engine_mcp/spine_core.py +626 -0
- engine_mcp/spine_server.py +150 -0
- engine_mcp-0.1.0.dist-info/METADATA +210 -0
- engine_mcp-0.1.0.dist-info/RECORD +10 -0
- engine_mcp-0.1.0.dist-info/WHEEL +5 -0
- engine_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- engine_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
- engine_mcp-0.1.0.dist-info/top_level.txt +1 -0
engine_mcp/__init__.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""godot_connect -- check a Godot MCP endpoint and register it with your MCP clients.
|
|
3
|
+
|
|
4
|
+
A Godot MCP addon serves an HTTP endpoint from the running editor (default
|
|
5
|
+
http://127.0.0.1:8000/mcp). This tool answers three separate questions:
|
|
6
|
+
is the addon installed in the project, is the endpoint live, and which
|
|
7
|
+
clients are registered to use it. Then it can register them in one command.
|
|
8
|
+
|
|
9
|
+
engine-mcp-godot --project path/to/godot_project doctor
|
|
10
|
+
engine-mcp-godot verify
|
|
11
|
+
engine-mcp-godot --clients claude,codex connect
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import socket
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
30
|
+
except (AttributeError, ValueError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
DEFAULT_URL = "http://127.0.0.1:8000/mcp"
|
|
34
|
+
DEFAULT_ADDON = "godot_ai"
|
|
35
|
+
CODEX_CONFIG = Path.home() / ".codex" / "config.toml"
|
|
36
|
+
GROK_CONFIG = Path.home() / ".grok" / "config.toml"
|
|
37
|
+
PROJECT_MCP = Path.cwd() / ".mcp.json" # overridden by --mcp-json
|
|
38
|
+
ALL_CLIENTS = ("claude", "codex", "grok", "hermes", "project")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def dumps(value: Any) -> str:
|
|
42
|
+
"""Pretty-print a value as UTF-8 JSON."""
|
|
43
|
+
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def endpoint_parts(url: str) -> tuple[str, int]:
|
|
47
|
+
"""Split an http(s) URL into (host, port); the port must be explicit."""
|
|
48
|
+
match = re.match(r"^https?://([^/:]+):(\d+)", url)
|
|
49
|
+
if not match:
|
|
50
|
+
raise ValueError(f"Godot MCP URL must include host and port: {url}")
|
|
51
|
+
return match.group(1), int(match.group(2))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_live(url: str) -> bool:
|
|
55
|
+
"""True if something accepts TCP connections on the URL's host and port."""
|
|
56
|
+
try:
|
|
57
|
+
host, port = endpoint_parts(url)
|
|
58
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
59
|
+
return True
|
|
60
|
+
except (OSError, ValueError):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def verify_endpoint(url: str) -> dict[str, Any]:
|
|
65
|
+
"""Send an MCP `initialize` request and report whether the endpoint answers 200."""
|
|
66
|
+
body = json.dumps(
|
|
67
|
+
{
|
|
68
|
+
"jsonrpc": "2.0",
|
|
69
|
+
"id": 1,
|
|
70
|
+
"method": "initialize",
|
|
71
|
+
"params": {
|
|
72
|
+
"protocolVersion": "2025-03-26",
|
|
73
|
+
"capabilities": {},
|
|
74
|
+
"clientInfo": {"name": "engine-mcp-godot-connect", "version": "1.0"},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
).encode("utf-8")
|
|
78
|
+
request = urllib.request.Request(
|
|
79
|
+
url,
|
|
80
|
+
data=body,
|
|
81
|
+
method="POST",
|
|
82
|
+
headers={
|
|
83
|
+
"Content-Type": "application/json",
|
|
84
|
+
"Accept": "application/json, text/event-stream",
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
try:
|
|
88
|
+
with urllib.request.urlopen(request, timeout=5) as response:
|
|
89
|
+
payload = response.read(65536).decode("utf-8", errors="replace")
|
|
90
|
+
return {
|
|
91
|
+
"ok": response.status == 200,
|
|
92
|
+
"status": response.status,
|
|
93
|
+
"response": payload[:4000],
|
|
94
|
+
}
|
|
95
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
96
|
+
return {"ok": False, "error": str(exc)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _upsert_toml(path: Path, name: str, url: str) -> None:
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
102
|
+
# drop the old table (and its sub-tables) up to the next table header, so
|
|
103
|
+
# values containing "[" (arrays) are removed whole
|
|
104
|
+
pattern = re.compile(rf"(?ms)^\[mcp_servers\.{re.escape(name)}(?:\.[^\]\n]+)?\]\n.*?(?=^\[|\Z)")
|
|
105
|
+
text = pattern.sub("", text).rstrip()
|
|
106
|
+
path.write_text(
|
|
107
|
+
text + f'\n\n[mcp_servers.{name}]\nurl = "{url}"\nenabled = true\n', encoding="utf-8"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _upsert_project(name: str, url: str) -> None:
|
|
112
|
+
data = {"mcpServers": {}}
|
|
113
|
+
if PROJECT_MCP.is_file():
|
|
114
|
+
data = json.loads(PROJECT_MCP.read_text(encoding="utf-8-sig"))
|
|
115
|
+
data.setdefault("mcpServers", {})[name] = {"type": "http", "url": url}
|
|
116
|
+
PROJECT_MCP.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _hermes_config() -> Path:
|
|
120
|
+
local = Path.home() / "AppData" / "Local" / "hermes" / "config.yaml"
|
|
121
|
+
return local if local.is_file() else Path.home() / ".hermes" / "config.yaml"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _upsert_hermes(name: str, url: str) -> None:
|
|
125
|
+
path = _hermes_config()
|
|
126
|
+
if not path.is_file():
|
|
127
|
+
raise FileNotFoundError(path)
|
|
128
|
+
text = path.read_text(encoding="utf-8")
|
|
129
|
+
block = f" {name}:\n url: {json.dumps(url)}\n enabled: true\n connect_timeout: 60.0\n"
|
|
130
|
+
text = re.sub(rf"(?m)^ {re.escape(name)}:\n(?: .*\n)*", "", text)
|
|
131
|
+
if re.search(r"(?m)^mcp_servers:\s*$", text):
|
|
132
|
+
text = re.sub(r"(?m)^(mcp_servers:\s*\n)", r"\1" + block, text, count=1)
|
|
133
|
+
else:
|
|
134
|
+
text = text.rstrip() + "\n\nmcp_servers:\n" + block
|
|
135
|
+
path.write_text(text, encoding="utf-8")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def register_client(client: str, name: str, url: str, scope: str) -> dict[str, Any]:
|
|
139
|
+
"""Register the endpoint with one client; returns {"ok": bool, ...} and never raises."""
|
|
140
|
+
try:
|
|
141
|
+
if client == "claude":
|
|
142
|
+
exe = shutil.which("claude")
|
|
143
|
+
if not exe:
|
|
144
|
+
raise FileNotFoundError("claude CLI")
|
|
145
|
+
subprocess.run(
|
|
146
|
+
[exe, "mcp", "remove", name, "--scope", scope], capture_output=True, timeout=30
|
|
147
|
+
)
|
|
148
|
+
result = subprocess.run(
|
|
149
|
+
[exe, "mcp", "add", "--transport", "http", name, url, "--scope", scope],
|
|
150
|
+
capture_output=True,
|
|
151
|
+
text=True,
|
|
152
|
+
timeout=30,
|
|
153
|
+
encoding="utf-8",
|
|
154
|
+
errors="replace",
|
|
155
|
+
)
|
|
156
|
+
if result.returncode:
|
|
157
|
+
raise RuntimeError(
|
|
158
|
+
(result.stderr or result.stdout or "claude mcp add failed").strip()
|
|
159
|
+
)
|
|
160
|
+
elif client == "codex":
|
|
161
|
+
exe = shutil.which("codex")
|
|
162
|
+
if exe:
|
|
163
|
+
subprocess.run([exe, "mcp", "remove", name], capture_output=True, timeout=30)
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
[exe, "mcp", "add", name, "--url", url],
|
|
166
|
+
capture_output=True,
|
|
167
|
+
text=True,
|
|
168
|
+
timeout=30,
|
|
169
|
+
encoding="utf-8",
|
|
170
|
+
errors="replace",
|
|
171
|
+
)
|
|
172
|
+
if result.returncode:
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
(result.stderr or result.stdout or "codex mcp add failed").strip()
|
|
175
|
+
)
|
|
176
|
+
_upsert_toml(CODEX_CONFIG, name, url)
|
|
177
|
+
elif client == "grok":
|
|
178
|
+
_upsert_toml(GROK_CONFIG, name, url)
|
|
179
|
+
elif client == "hermes":
|
|
180
|
+
_upsert_hermes(name, url)
|
|
181
|
+
elif client == "project":
|
|
182
|
+
_upsert_project(name, url)
|
|
183
|
+
else:
|
|
184
|
+
raise ValueError(f"unknown client: {client}")
|
|
185
|
+
return {"ok": True, "client": client, "url": url, "restart_required": True}
|
|
186
|
+
except (OSError, RuntimeError, ValueError, json.JSONDecodeError) as exc:
|
|
187
|
+
return {"ok": False, "client": client, "url": url, "error": str(exc)}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def registration_status(name: str) -> dict[str, Any]:
|
|
191
|
+
"""Report, per client, whether a server called `name` is registered."""
|
|
192
|
+
result: dict[str, Any] = {}
|
|
193
|
+
for label, path in (("codex", CODEX_CONFIG), ("grok", GROK_CONFIG)):
|
|
194
|
+
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
195
|
+
result[label] = f"[mcp_servers.{name}]" in text
|
|
196
|
+
project = {}
|
|
197
|
+
if PROJECT_MCP.is_file():
|
|
198
|
+
try:
|
|
199
|
+
project = json.loads(PROJECT_MCP.read_text(encoding="utf-8-sig")).get("mcpServers", {})
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
project = {}
|
|
202
|
+
result["project"] = name in project
|
|
203
|
+
hermes = _hermes_config()
|
|
204
|
+
result["hermes"] = hermes.is_file() and bool(
|
|
205
|
+
re.search(rf"(?m)^ {re.escape(name)}:", hermes.read_text(encoding="utf-8"))
|
|
206
|
+
)
|
|
207
|
+
result["claude"] = "probe with `claude mcp list`"
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main(argv: list[str] | None = None) -> int:
|
|
212
|
+
"""CLI entry point; returns a process exit code."""
|
|
213
|
+
global PROJECT_MCP
|
|
214
|
+
parser = argparse.ArgumentParser(description="Godot MCP multi-client connector")
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
"--project", default=".", help="Godot project folder (contains project.godot)"
|
|
217
|
+
)
|
|
218
|
+
parser.add_argument("--addon", default=DEFAULT_ADDON, help="addon folder name under addons/")
|
|
219
|
+
parser.add_argument(
|
|
220
|
+
"--mcp-json", default="", help="project .mcp.json to update (default: ./.mcp.json)"
|
|
221
|
+
)
|
|
222
|
+
parser.add_argument("--name", default="godot-ai", help="server name to register")
|
|
223
|
+
parser.add_argument("--url", default=DEFAULT_URL)
|
|
224
|
+
parser.add_argument("--scope", choices=("local", "project", "user"), default="local")
|
|
225
|
+
parser.add_argument("--clients", default="all", help="claude,codex,grok,hermes,project or all")
|
|
226
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
227
|
+
for name in ("status", "doctor", "verify", "connect"):
|
|
228
|
+
sub.add_parser(name)
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
if args.mcp_json:
|
|
231
|
+
PROJECT_MCP = Path(args.mcp_json)
|
|
232
|
+
project = Path(args.project).resolve()
|
|
233
|
+
addon = project / "addons" / args.addon / "plugin.cfg"
|
|
234
|
+
|
|
235
|
+
if args.command == "status":
|
|
236
|
+
print(
|
|
237
|
+
dumps(
|
|
238
|
+
{
|
|
239
|
+
"url": args.url,
|
|
240
|
+
"live": is_live(args.url),
|
|
241
|
+
"registrations": registration_status(args.name),
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
)
|
|
245
|
+
return 0
|
|
246
|
+
if args.command == "doctor":
|
|
247
|
+
live = is_live(args.url)
|
|
248
|
+
payload = {
|
|
249
|
+
"ok": (project / "project.godot").is_file() and addon.is_file(),
|
|
250
|
+
"project": str(project),
|
|
251
|
+
"project_exists": (project / "project.godot").is_file(),
|
|
252
|
+
"addon_installed": addon.is_file(),
|
|
253
|
+
"endpoint": args.url,
|
|
254
|
+
"live": live,
|
|
255
|
+
"registrations": registration_status(args.name),
|
|
256
|
+
"next": "Run verify, then restart MCP clients after registration"
|
|
257
|
+
if live
|
|
258
|
+
else "Open Godot with the MCP addon enabled",
|
|
259
|
+
}
|
|
260
|
+
print(dumps(payload))
|
|
261
|
+
return 0 if payload["ok"] else 1
|
|
262
|
+
if args.command == "verify":
|
|
263
|
+
result = verify_endpoint(args.url)
|
|
264
|
+
print(dumps(result))
|
|
265
|
+
return 0 if result.get("ok") else 1
|
|
266
|
+
if args.command == "connect":
|
|
267
|
+
clients = (
|
|
268
|
+
list(ALL_CLIENTS)
|
|
269
|
+
if args.clients == "all"
|
|
270
|
+
else [c.strip() for c in args.clients.split(",") if c.strip()]
|
|
271
|
+
)
|
|
272
|
+
results = [register_client(c, args.name, args.url, args.scope) for c in clients]
|
|
273
|
+
print(dumps({"ok": all(r["ok"] for r in results), "results": results}))
|
|
274
|
+
return 0 if all(r["ok"] for r in results) else 1
|
|
275
|
+
return 2
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
raise SystemExit(main())
|
engine_mcp/spine_core.py
ADDED
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""spine_core -- operations behind the Spine MCP server.
|
|
3
|
+
|
|
4
|
+
Three lanes:
|
|
5
|
+
EDITOR lane -> official Spine CLI: version / export / import / pack / unpack / clean / open
|
|
6
|
+
PREP lane -> repair skeleton JSON so the Spine 4.x editor importer accepts it
|
|
7
|
+
SCENE lane -> pure-JSON multi-skeleton compositor and layout preview
|
|
8
|
+
|
|
9
|
+
Every function returns a dict with an "ok" bool so MCP tools and scripts share one contract.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import copy
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
SPINE_EXE = os.environ.get("SPINE_EXE", r"C:\Program Files\Spine\Spine.com")
|
|
24
|
+
SPINE_UPDATE = os.environ.get("SPINE_UPDATE", "") # pin editor version, e.g. "4.3.23"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _run(cmd: list[str], timeout: int = 600) -> dict[str, Any]:
|
|
28
|
+
start = time.time()
|
|
29
|
+
try:
|
|
30
|
+
proc = subprocess.run(
|
|
31
|
+
cmd,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
text=True,
|
|
34
|
+
encoding="utf-8",
|
|
35
|
+
errors="replace",
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
)
|
|
38
|
+
return {
|
|
39
|
+
"ok": proc.returncode == 0,
|
|
40
|
+
"exit_code": proc.returncode,
|
|
41
|
+
"command": subprocess.list2cmdline(cmd),
|
|
42
|
+
"stdout": (proc.stdout or "")[-8000:],
|
|
43
|
+
"stderr": (proc.stderr or "")[-4000:],
|
|
44
|
+
"seconds": round(time.time() - start, 1),
|
|
45
|
+
}
|
|
46
|
+
except subprocess.TimeoutExpired:
|
|
47
|
+
return {
|
|
48
|
+
"ok": False,
|
|
49
|
+
"exit_code": -1,
|
|
50
|
+
"command": subprocess.list2cmdline(cmd),
|
|
51
|
+
"stdout": "",
|
|
52
|
+
"stderr": f"timeout after {timeout}s",
|
|
53
|
+
"seconds": timeout,
|
|
54
|
+
}
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
return {
|
|
57
|
+
"ok": False,
|
|
58
|
+
"exit_code": -1,
|
|
59
|
+
"command": subprocess.list2cmdline(cmd),
|
|
60
|
+
"stdout": "",
|
|
61
|
+
"stderr": str(exc),
|
|
62
|
+
"seconds": 0,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _spine(args: list[str], timeout: int = 600) -> dict[str, Any]:
|
|
67
|
+
if not Path(SPINE_EXE).exists():
|
|
68
|
+
return {
|
|
69
|
+
"ok": False,
|
|
70
|
+
"stderr": f"Spine CLI not found at {SPINE_EXE}. "
|
|
71
|
+
"Set SPINE_EXE env var to Spine.com path.",
|
|
72
|
+
}
|
|
73
|
+
cmd = [SPINE_EXE]
|
|
74
|
+
if SPINE_UPDATE:
|
|
75
|
+
cmd += ["--update", SPINE_UPDATE]
|
|
76
|
+
return _run(cmd + args, timeout=timeout)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def spine_version() -> dict[str, Any]:
|
|
80
|
+
"""Report the Spine editor/launcher version and the CLI path in use."""
|
|
81
|
+
r = _spine(["-v"], timeout=180)
|
|
82
|
+
r["spine_exe"] = SPINE_EXE
|
|
83
|
+
return r
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def spine_export(
|
|
87
|
+
input_path: str, output_path: str, export: str = "json", clean: bool = False, version: str = ""
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""Export a .spine project or data file. `export` is a shorthand
|
|
90
|
+
(json, binary, json+pack, binary+pack) or a path to an export settings JSON
|
|
91
|
+
saved from the editor's Export dialog (required for PNG/GIF/video)."""
|
|
92
|
+
args = []
|
|
93
|
+
if version:
|
|
94
|
+
args += ["--update", version]
|
|
95
|
+
args += ["-i", input_path, "-o", output_path]
|
|
96
|
+
if clean:
|
|
97
|
+
args += ["-m"]
|
|
98
|
+
args += ["-e", export]
|
|
99
|
+
r = _spine(args)
|
|
100
|
+
r["output"] = output_path
|
|
101
|
+
return r
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def spine_import(
|
|
105
|
+
data_path: str, project_path: str, name: str = "", scale: float = 0.0, prep: bool = True
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""Import skeleton JSON / .skel data into a .spine project file.
|
|
108
|
+
prep=True (default) first runs rig_editor_prep (generated JSON -> editor-valid JSON).
|
|
109
|
+
Pass prep=False to import the file as-is."""
|
|
110
|
+
if prep and data_path.endswith(".json"):
|
|
111
|
+
prepped = rig_editor_prep(data_path)
|
|
112
|
+
if not prepped.get("ok"):
|
|
113
|
+
return prepped
|
|
114
|
+
data_path = prepped["editor_json"]
|
|
115
|
+
args = ["-i", data_path, "-o", project_path]
|
|
116
|
+
if scale:
|
|
117
|
+
args += ["-s", str(scale)]
|
|
118
|
+
args += ["-r"]
|
|
119
|
+
if name:
|
|
120
|
+
args += [name]
|
|
121
|
+
r = _spine(args)
|
|
122
|
+
r["project"] = project_path
|
|
123
|
+
return r
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def spine_pack(
|
|
127
|
+
images_dir: str,
|
|
128
|
+
output_dir: str,
|
|
129
|
+
name: str = "atlas",
|
|
130
|
+
pack_settings: str = "",
|
|
131
|
+
project: str = "",
|
|
132
|
+
) -> dict[str, Any]:
|
|
133
|
+
"""Pack a folder of images into a texture atlas."""
|
|
134
|
+
args = ["-i", images_dir]
|
|
135
|
+
if project:
|
|
136
|
+
args += ["-j", project]
|
|
137
|
+
args += ["-o", output_dir]
|
|
138
|
+
if pack_settings:
|
|
139
|
+
args += ["-n", name, "-p", pack_settings]
|
|
140
|
+
else:
|
|
141
|
+
args += ["-p", name]
|
|
142
|
+
return _spine(args)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def spine_unpack(atlas_path: str, images_dir: str, output_dir: str) -> dict[str, Any]:
|
|
146
|
+
"""Unpack a texture atlas back into individual images."""
|
|
147
|
+
return _spine(["-i", images_dir, "-o", output_dir, "-c", atlas_path])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def spine_clean(project_path: str, output_path: str = "") -> dict[str, Any]:
|
|
151
|
+
"""Run Spine animation cleanup on a project."""
|
|
152
|
+
out = output_path or project_path
|
|
153
|
+
return _spine(["-i", project_path, "-o", out, "-m", "-e", "json"])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def spine_open_editor(project_path: str = "") -> dict[str, Any]:
|
|
157
|
+
"""Open the Spine editor GUI (non-blocking), optionally with a project."""
|
|
158
|
+
exe = SPINE_EXE.replace("Spine.com", "Spine.exe")
|
|
159
|
+
if not Path(exe).exists():
|
|
160
|
+
exe = SPINE_EXE
|
|
161
|
+
try:
|
|
162
|
+
cmd = [exe] + ([project_path] if project_path else [])
|
|
163
|
+
subprocess.Popen(cmd, creationflags=getattr(subprocess, "DETACHED_PROCESS", 0))
|
|
164
|
+
return {"ok": True, "launched": exe, "project": project_path or "(none)"}
|
|
165
|
+
except OSError as exc:
|
|
166
|
+
return {"ok": False, "stderr": str(exc)}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def analyze_alpha(image: str) -> dict[str, Any]:
|
|
170
|
+
"""Inspect an image's alpha channel to decide whether it needs a cutout pass.
|
|
171
|
+
keyed=True (already transparent) -> skip cutout; otherwise cutout needed."""
|
|
172
|
+
try:
|
|
173
|
+
from PIL import Image
|
|
174
|
+
except ImportError:
|
|
175
|
+
return {"ok": False, "stderr": "Pillow not installed (pip install pillow)"}
|
|
176
|
+
p = Path(image)
|
|
177
|
+
if not p.exists():
|
|
178
|
+
return {"ok": False, "stderr": f"image not found: {p}"}
|
|
179
|
+
img = Image.open(p)
|
|
180
|
+
if img.mode != "RGBA":
|
|
181
|
+
return {
|
|
182
|
+
"ok": True,
|
|
183
|
+
"mode": img.mode,
|
|
184
|
+
"size": list(img.size),
|
|
185
|
+
"keyed": False,
|
|
186
|
+
"transparent_pct": 0.0,
|
|
187
|
+
"opaque_pct": 100.0,
|
|
188
|
+
"partial_pct": 0.0,
|
|
189
|
+
"verdict": "no alpha channel -> cutout needed",
|
|
190
|
+
}
|
|
191
|
+
hist = img.getchannel("A").histogram()
|
|
192
|
+
total = img.width * img.height
|
|
193
|
+
transparent = sum(hist[:16]) / total * 100
|
|
194
|
+
opaque = sum(hist[240:]) / total * 100
|
|
195
|
+
partial = 100 - transparent - opaque
|
|
196
|
+
keyed = transparent >= 3.0
|
|
197
|
+
return {
|
|
198
|
+
"ok": True,
|
|
199
|
+
"mode": "RGBA",
|
|
200
|
+
"size": list(img.size),
|
|
201
|
+
"keyed": keyed,
|
|
202
|
+
"transparent_pct": round(transparent, 1),
|
|
203
|
+
"opaque_pct": round(opaque, 1),
|
|
204
|
+
"partial_pct": round(partial, 1),
|
|
205
|
+
"verdict": "already keyed -> use alpha directly"
|
|
206
|
+
if keyed
|
|
207
|
+
else "opaque alpha -> cutout needed",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _world_transforms(bones: list[dict[str, Any]]) -> dict[str, Any]:
|
|
212
|
+
world = {}
|
|
213
|
+
for b in bones: # assumes parents precede children, as in Spine JSON
|
|
214
|
+
rot = math.radians(b.get("rotation", 0))
|
|
215
|
+
sx, sy = b.get("scaleX", 1), b.get("scaleY", 1)
|
|
216
|
+
la, lb = math.cos(rot) * sx, -math.sin(rot) * sy
|
|
217
|
+
lc, ld = math.sin(rot) * sx, math.cos(rot) * sy
|
|
218
|
+
x, y = b.get("x", 0), b.get("y", 0)
|
|
219
|
+
p = world.get(b.get("parent"))
|
|
220
|
+
if p is None:
|
|
221
|
+
world[b["name"]] = (la, lb, lc, ld, x, y)
|
|
222
|
+
else:
|
|
223
|
+
pa, pb, pc, pd, px, py = p
|
|
224
|
+
world[b["name"]] = (
|
|
225
|
+
pa * la + pb * lc,
|
|
226
|
+
pa * lb + pb * ld,
|
|
227
|
+
pc * la + pd * lc,
|
|
228
|
+
pc * lb + pd * ld,
|
|
229
|
+
pa * x + pb * y + px,
|
|
230
|
+
pc * x + pd * y + py,
|
|
231
|
+
)
|
|
232
|
+
return world
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _unweight_mesh(
|
|
236
|
+
att: dict[str, Any], world: dict[str, Any], bones: list[dict[str, Any]], slot_bone: str
|
|
237
|
+
) -> bool:
|
|
238
|
+
verts = att.get("vertices")
|
|
239
|
+
uvs = att.get("uvs")
|
|
240
|
+
if not isinstance(verts, list) or not isinstance(uvs, list):
|
|
241
|
+
return False
|
|
242
|
+
n = len(uvs) // 2
|
|
243
|
+
if len(verts) == n * 2:
|
|
244
|
+
return False # already unweighted
|
|
245
|
+
sa, sb, sc, sd, sx, sy = world[slot_bone]
|
|
246
|
+
det = sa * sd - sb * sc or 1e-9
|
|
247
|
+
ia, ib, ic, id_ = sd / det, -sb / det, -sc / det, sa / det
|
|
248
|
+
out = []
|
|
249
|
+
i = 0
|
|
250
|
+
for _ in range(n):
|
|
251
|
+
cnt = int(verts[i])
|
|
252
|
+
i += 1
|
|
253
|
+
wx = wy = 0.0
|
|
254
|
+
for _ in range(cnt):
|
|
255
|
+
bi, lx, ly, w = int(verts[i]), verts[i + 1], verts[i + 2], verts[i + 3]
|
|
256
|
+
i += 4
|
|
257
|
+
ba, bb, bc, bd, bx, by = world[bones[bi]["name"]]
|
|
258
|
+
wx += (ba * lx + bb * ly + bx) * w
|
|
259
|
+
wy += (bc * lx + bd * ly + by) * w
|
|
260
|
+
dx, dy = wx - sx, wy - sy
|
|
261
|
+
out += [ia * dx + ib * dy, ic * dx + id_ * dy]
|
|
262
|
+
att["vertices"] = out
|
|
263
|
+
return True
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _clean_keys(frames: list[dict[str, Any]]) -> list:
|
|
267
|
+
out = []
|
|
268
|
+
for f in frames:
|
|
269
|
+
f = dict(f)
|
|
270
|
+
if f.get("curve") == "linear":
|
|
271
|
+
del f["curve"]
|
|
272
|
+
out.append(f)
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
_TIMELINE_VALUE_KEYS = {
|
|
277
|
+
"rotate": ("value",),
|
|
278
|
+
"translate": ("x", "y"),
|
|
279
|
+
"scale": ("x", "y"),
|
|
280
|
+
"shear": ("x", "y"),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _fix_curves(frames: list[dict[str, Any]], val_keys: tuple) -> None:
|
|
285
|
+
"""Convert 3.x normalized Bezier curves [a,b,c,d] to Spine 4.x absolute
|
|
286
|
+
control points (4 values per value component), in place."""
|
|
287
|
+
for i, f in enumerate(frames):
|
|
288
|
+
c = f.get("curve")
|
|
289
|
+
if not (isinstance(c, list) and len(c) == 4):
|
|
290
|
+
continue
|
|
291
|
+
if i + 1 >= len(frames):
|
|
292
|
+
f.pop("curve", None)
|
|
293
|
+
continue
|
|
294
|
+
nf = frames[i + 1]
|
|
295
|
+
t0, t1 = f.get("time", 0), nf.get("time", 0)
|
|
296
|
+
a, b, cx, d = c
|
|
297
|
+
new = []
|
|
298
|
+
for k in val_keys:
|
|
299
|
+
v0, v1 = f.get(k, 0), nf.get(k, 0)
|
|
300
|
+
new += [t0 + a * (t1 - t0), v0 + b * (v1 - v0), t0 + cx * (t1 - t0), v0 + d * (v1 - v0)]
|
|
301
|
+
f["curve"] = new
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _normalize_animation(anim: dict[str, Any]) -> dict[str, Any]:
|
|
305
|
+
fixed = {}
|
|
306
|
+
for section, body in anim.items():
|
|
307
|
+
if section == "name":
|
|
308
|
+
continue # stray key some generators write; not Spine JSON
|
|
309
|
+
if section == "bones":
|
|
310
|
+
new_bones = {}
|
|
311
|
+
for bone, tl in body.items():
|
|
312
|
+
if isinstance(tl, dict):
|
|
313
|
+
new_tl = {}
|
|
314
|
+
for kind, frames in tl.items():
|
|
315
|
+
frames = _clean_keys(frames)
|
|
316
|
+
if kind == "rotate":
|
|
317
|
+
for f in frames:
|
|
318
|
+
if "value" not in f:
|
|
319
|
+
f["value"] = f.pop("rotation", f.pop("angle", 0))
|
|
320
|
+
if kind in _TIMELINE_VALUE_KEYS:
|
|
321
|
+
_fix_curves(frames, _TIMELINE_VALUE_KEYS[kind])
|
|
322
|
+
new_tl[kind] = frames
|
|
323
|
+
new_bones[bone] = new_tl
|
|
324
|
+
elif isinstance(tl, list): # raw per-bone keyframe list
|
|
325
|
+
rot, trans, scale = [], [], []
|
|
326
|
+
for f in _clean_keys(tl):
|
|
327
|
+
base = {"time": f.get("time", 0)}
|
|
328
|
+
if "curve" in f:
|
|
329
|
+
base["curve"] = f["curve"]
|
|
330
|
+
if "rotation" in f or "angle" in f:
|
|
331
|
+
rot.append({**base, "value": f.get("rotation", f.get("angle", 0))})
|
|
332
|
+
if "x" in f or "y" in f:
|
|
333
|
+
trans.append({**base, "x": f.get("x", 0), "y": f.get("y", 0)})
|
|
334
|
+
if "scaleX" in f or "scaleY" in f:
|
|
335
|
+
scale.append({**base, "x": f.get("scaleX", 1), "y": f.get("scaleY", 1)})
|
|
336
|
+
new_tl = {}
|
|
337
|
+
if rot:
|
|
338
|
+
_fix_curves(rot, _TIMELINE_VALUE_KEYS["rotate"])
|
|
339
|
+
new_tl["rotate"] = rot
|
|
340
|
+
if trans:
|
|
341
|
+
_fix_curves(trans, _TIMELINE_VALUE_KEYS["translate"])
|
|
342
|
+
new_tl["translate"] = trans
|
|
343
|
+
if scale:
|
|
344
|
+
_fix_curves(scale, _TIMELINE_VALUE_KEYS["scale"])
|
|
345
|
+
new_tl["scale"] = scale
|
|
346
|
+
if new_tl:
|
|
347
|
+
new_bones[bone] = new_tl
|
|
348
|
+
else:
|
|
349
|
+
new_bones[bone] = tl
|
|
350
|
+
fixed["bones"] = new_bones
|
|
351
|
+
elif section == "slots" and isinstance(body, dict):
|
|
352
|
+
new_slots = {}
|
|
353
|
+
for slot, tl in body.items():
|
|
354
|
+
if isinstance(tl, list): # raw list -> attachment timeline
|
|
355
|
+
new_slots[slot] = {
|
|
356
|
+
"attachment": [
|
|
357
|
+
{"time": f.get("time", 0), "name": f.get("name", f.get("attachment"))}
|
|
358
|
+
for f in tl
|
|
359
|
+
]
|
|
360
|
+
}
|
|
361
|
+
else:
|
|
362
|
+
new_slots[slot] = tl
|
|
363
|
+
if new_slots:
|
|
364
|
+
fixed["slots"] = new_slots
|
|
365
|
+
else:
|
|
366
|
+
fixed[section] = body
|
|
367
|
+
return fixed
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def rig_editor_prep(rig_json: str, output: str = "") -> dict[str, Any]:
|
|
371
|
+
"""Repair generated rig JSON into Spine-editor-importable form:
|
|
372
|
+
weighted meshes -> unweighted (editor re-binds weights natively),
|
|
373
|
+
loose keyframe lists -> valid Spine 4.x timelines. Writes a new file
|
|
374
|
+
(<stem>_editor.json by default); the source is left untouched."""
|
|
375
|
+
src = Path(rig_json)
|
|
376
|
+
if not src.exists():
|
|
377
|
+
return {"ok": False, "stderr": f"rig not found: {src}"}
|
|
378
|
+
data = json.loads(src.read_text(encoding="utf-8-sig"))
|
|
379
|
+
bones = data.get("bones", [])
|
|
380
|
+
world = _world_transforms(bones)
|
|
381
|
+
slot_bone = {s["name"]: s["bone"] for s in data.get("slots", [])}
|
|
382
|
+
|
|
383
|
+
unweighted = 0
|
|
384
|
+
skins = data.get("skins", [])
|
|
385
|
+
if isinstance(skins, dict):
|
|
386
|
+
skins = [{"name": k, "attachments": v} for k, v in skins.items()]
|
|
387
|
+
data["skins"] = skins
|
|
388
|
+
for skin in skins:
|
|
389
|
+
for slot, atts in skin.get("attachments", {}).items():
|
|
390
|
+
for att in atts.values():
|
|
391
|
+
if att.get("type") == "mesh" and slot in slot_bone:
|
|
392
|
+
if _unweight_mesh(att, world, bones, slot_bone[slot]):
|
|
393
|
+
unweighted += 1
|
|
394
|
+
|
|
395
|
+
anims = data.get("animations", {})
|
|
396
|
+
data["animations"] = {k: _normalize_animation(v) for k, v in anims.items()}
|
|
397
|
+
|
|
398
|
+
out = Path(output) if output else src.with_name(src.stem + "_editor.json")
|
|
399
|
+
out.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
400
|
+
return {
|
|
401
|
+
"ok": True,
|
|
402
|
+
"editor_json": str(out),
|
|
403
|
+
"meshes_unweighted": unweighted,
|
|
404
|
+
"animations_normalized": len(anims),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _offset_attachment_bone_indices(att: dict[str, Any], offset: int) -> None:
|
|
409
|
+
verts = att.get("vertices")
|
|
410
|
+
if not isinstance(verts, list):
|
|
411
|
+
return
|
|
412
|
+
if "uvs" in att:
|
|
413
|
+
weighted = len(verts) != len(att["uvs"])
|
|
414
|
+
elif "vertexCount" in att:
|
|
415
|
+
weighted = len(verts) != att["vertexCount"] * 2
|
|
416
|
+
else:
|
|
417
|
+
return
|
|
418
|
+
if not weighted:
|
|
419
|
+
return
|
|
420
|
+
i = 0
|
|
421
|
+
while i < len(verts):
|
|
422
|
+
n = int(verts[i])
|
|
423
|
+
i += 1
|
|
424
|
+
for _ in range(n):
|
|
425
|
+
verts[i] = int(verts[i]) + offset
|
|
426
|
+
i += 4
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _rename_anim_block(
|
|
430
|
+
anim: dict[str, Any],
|
|
431
|
+
bone_map: dict[str, Any],
|
|
432
|
+
slot_map: dict[str, Any],
|
|
433
|
+
con_map: dict[str, Any],
|
|
434
|
+
skin_map: dict[str, Any],
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
out = {}
|
|
437
|
+
for section, body in anim.items():
|
|
438
|
+
if section == "bones":
|
|
439
|
+
out[section] = {bone_map.get(k, k): v for k, v in body.items()}
|
|
440
|
+
elif section == "slots":
|
|
441
|
+
out[section] = {slot_map.get(k, k): v for k, v in body.items()}
|
|
442
|
+
elif section in ("ik", "transform", "path", "physics"):
|
|
443
|
+
out[section] = {con_map.get(k, k): v for k, v in body.items()}
|
|
444
|
+
elif section == "deform":
|
|
445
|
+
out[section] = {
|
|
446
|
+
skin_map.get(sk, sk): {slot_map.get(sl, sl): atts for sl, atts in slots.items()}
|
|
447
|
+
for sk, slots in body.items()
|
|
448
|
+
}
|
|
449
|
+
elif section == "drawOrder" or section == "draworder":
|
|
450
|
+
new = copy.deepcopy(body)
|
|
451
|
+
for frame in new:
|
|
452
|
+
for off in frame.get("offsets", []):
|
|
453
|
+
off["slot"] = slot_map.get(off["slot"], off["slot"])
|
|
454
|
+
out["drawOrder"] = new
|
|
455
|
+
else:
|
|
456
|
+
out[section] = body
|
|
457
|
+
return out
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def scene_compose(
|
|
461
|
+
characters: list[dict[str, Any]], output_json: str, scene_name: str = "scene"
|
|
462
|
+
) -> dict[str, Any]:
|
|
463
|
+
"""Merge N rigged characters into ONE Spine skeleton JSON (a scene).
|
|
464
|
+
|
|
465
|
+
characters: list[dict[str, Any]] of dicts:
|
|
466
|
+
{"json": path, "prefix": name, "x": 0, "y": 0, "scale": 1.0, "rotation": 0}
|
|
467
|
+
Each character's bones/slots/skins/constraints/animations are prefixed and
|
|
468
|
+
grafted under a per-character root placed at (x, y) * scale. Weighted-mesh
|
|
469
|
+
bone indices are re-based so deformation survives the merge.
|
|
470
|
+
"""
|
|
471
|
+
if not characters:
|
|
472
|
+
return {"ok": False, "stderr": "no characters given"}
|
|
473
|
+
merged = {
|
|
474
|
+
"skeleton": {"spine": "4.0", "hash": scene_name, "images": "./images/"},
|
|
475
|
+
"bones": [{"name": "root"}],
|
|
476
|
+
"slots": [],
|
|
477
|
+
"ik": [],
|
|
478
|
+
"transform": [],
|
|
479
|
+
"skins": [],
|
|
480
|
+
"animations": {},
|
|
481
|
+
}
|
|
482
|
+
default_skin = {"name": "default", "attachments": {}}
|
|
483
|
+
merged["skins"].append(default_skin)
|
|
484
|
+
report = []
|
|
485
|
+
|
|
486
|
+
for idx, spec in enumerate(characters):
|
|
487
|
+
path = Path(spec["json"])
|
|
488
|
+
if not path.exists():
|
|
489
|
+
return {"ok": False, "stderr": f"rig not found: {path}"}
|
|
490
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
491
|
+
prefix = (spec.get("prefix") or path.stem.split("_")[0] or f"char{idx}") + "_"
|
|
492
|
+
|
|
493
|
+
bones = copy.deepcopy(data.get("bones", []))
|
|
494
|
+
slots = copy.deepcopy(data.get("slots", []))
|
|
495
|
+
skins = copy.deepcopy(data.get("skins", []))
|
|
496
|
+
if isinstance(skins, dict): # legacy 3.x dict form
|
|
497
|
+
skins = [{"name": k, "attachments": v} for k, v in skins.items()]
|
|
498
|
+
iks = copy.deepcopy(data.get("ik", []))
|
|
499
|
+
transforms = copy.deepcopy(data.get("transform", []))
|
|
500
|
+
anims = copy.deepcopy(data.get("animations", {}))
|
|
501
|
+
|
|
502
|
+
bone_map = {b["name"]: prefix + b["name"] for b in bones}
|
|
503
|
+
slot_map = {s["name"]: prefix + s["name"] for s in slots}
|
|
504
|
+
con_map = {c["name"]: prefix + c["name"] for c in iks + transforms}
|
|
505
|
+
skin_map = {s["name"]: (prefix + s["name"]) for s in skins if s["name"] != "default"}
|
|
506
|
+
|
|
507
|
+
bone_offset = len(merged["bones"])
|
|
508
|
+
for b in bones:
|
|
509
|
+
b["name"] = bone_map[b["name"]]
|
|
510
|
+
if "parent" in b:
|
|
511
|
+
b["parent"] = bone_map.get(b["parent"], b["parent"])
|
|
512
|
+
else: # this character's root: place it in the scene
|
|
513
|
+
b["parent"] = "root"
|
|
514
|
+
b["x"] = float(b.get("x", 0)) + float(spec.get("x", 0))
|
|
515
|
+
b["y"] = float(b.get("y", 0)) + float(spec.get("y", 0))
|
|
516
|
+
if spec.get("scale"):
|
|
517
|
+
b["scaleX"] = float(b.get("scaleX", 1)) * float(spec["scale"])
|
|
518
|
+
b["scaleY"] = float(b.get("scaleY", 1)) * float(spec["scale"])
|
|
519
|
+
if spec.get("rotation"):
|
|
520
|
+
b["rotation"] = float(b.get("rotation", 0)) + float(spec["rotation"])
|
|
521
|
+
merged["bones"].extend(bones)
|
|
522
|
+
|
|
523
|
+
for s in slots:
|
|
524
|
+
s["name"] = slot_map[s["name"]]
|
|
525
|
+
s["bone"] = bone_map.get(s["bone"], s["bone"])
|
|
526
|
+
merged["slots"].extend(slots)
|
|
527
|
+
|
|
528
|
+
for group, target in ((iks, "ik"), (transforms, "transform")):
|
|
529
|
+
for c in group:
|
|
530
|
+
c["name"] = con_map[c["name"]]
|
|
531
|
+
c["order"] = int(c.get("order", 0)) + idx * 100
|
|
532
|
+
c["bones"] = [bone_map.get(x, x) for x in c.get("bones", [])]
|
|
533
|
+
if "target" in c:
|
|
534
|
+
c["target"] = bone_map.get(c["target"], c["target"])
|
|
535
|
+
merged[target].append(c)
|
|
536
|
+
|
|
537
|
+
for skin in skins:
|
|
538
|
+
new_atts = {}
|
|
539
|
+
for slot_name, atts in skin.get("attachments", {}).items():
|
|
540
|
+
for att in atts.values():
|
|
541
|
+
# weighted-mesh bone indices point into the skeleton bones
|
|
542
|
+
# array; this character's bone 0 now sits at bone_offset
|
|
543
|
+
_offset_attachment_bone_indices(att, bone_offset)
|
|
544
|
+
new_atts[slot_map.get(slot_name, slot_name)] = atts
|
|
545
|
+
if skin["name"] == "default":
|
|
546
|
+
default_skin["attachments"].update(new_atts)
|
|
547
|
+
else:
|
|
548
|
+
merged["skins"].append({"name": skin_map[skin["name"]], "attachments": new_atts})
|
|
549
|
+
|
|
550
|
+
for anim_name, anim in anims.items():
|
|
551
|
+
merged["animations"][prefix + anim_name] = _rename_anim_block(
|
|
552
|
+
anim, bone_map, slot_map, con_map, skin_map
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
report.append(
|
|
556
|
+
{
|
|
557
|
+
"character": prefix.rstrip("_"),
|
|
558
|
+
"bones": len(bones),
|
|
559
|
+
"slots": len(slots),
|
|
560
|
+
"animations": len(anims),
|
|
561
|
+
}
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
out = Path(output_json)
|
|
565
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
566
|
+
out.write_text(json.dumps(merged, ensure_ascii=False), encoding="utf-8")
|
|
567
|
+
return {
|
|
568
|
+
"ok": True,
|
|
569
|
+
"scene_json": str(out),
|
|
570
|
+
"characters": report,
|
|
571
|
+
"total_bones": len(merged["bones"]),
|
|
572
|
+
"total_slots": len(merged["slots"]),
|
|
573
|
+
"total_animations": len(merged["animations"]),
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def scene_preview(
|
|
578
|
+
characters: list[dict[str, Any]],
|
|
579
|
+
output_png: str,
|
|
580
|
+
width: int = 1920,
|
|
581
|
+
height: int = 1080,
|
|
582
|
+
background: str = "#202028",
|
|
583
|
+
) -> dict[str, Any]:
|
|
584
|
+
"""Composite each character's texture at its scene placement -> layout PNG.
|
|
585
|
+
Fast visual proof of a scene; full skeletal renders go through spine_export
|
|
586
|
+
with a saved PNG/GIF export-settings file."""
|
|
587
|
+
try:
|
|
588
|
+
from PIL import Image
|
|
589
|
+
except ImportError:
|
|
590
|
+
return {"ok": False, "stderr": "Pillow not installed (pip install pillow)"}
|
|
591
|
+
canvas = Image.new("RGBA", (width, height), background)
|
|
592
|
+
placed = []
|
|
593
|
+
for spec in characters:
|
|
594
|
+
tex_path = spec.get("texture", "")
|
|
595
|
+
if not tex_path:
|
|
596
|
+
rig = Path(spec.get("json", ""))
|
|
597
|
+
guess = rig.with_suffix(".png")
|
|
598
|
+
if not guess.exists():
|
|
599
|
+
stem = rig.stem.split("_")[0]
|
|
600
|
+
guess = rig.with_name(stem + ".png")
|
|
601
|
+
tex_path = str(guess)
|
|
602
|
+
if not Path(tex_path).exists():
|
|
603
|
+
return {"ok": False, "stderr": f"texture not found for scene preview: {tex_path}"}
|
|
604
|
+
img = Image.open(tex_path).convert("RGBA")
|
|
605
|
+
scale = float(spec.get("scale", 1.0))
|
|
606
|
+
if scale != 1.0:
|
|
607
|
+
img = img.resize(
|
|
608
|
+
(max(1, int(img.width * scale)), max(1, int(img.height * scale))), Image.LANCZOS
|
|
609
|
+
)
|
|
610
|
+
x = int(width / 2 + float(spec.get("x", 0)) - img.width / 2)
|
|
611
|
+
y = int(height - float(spec.get("y", 0)) - img.height)
|
|
612
|
+
canvas.alpha_composite(img, (x, y))
|
|
613
|
+
placed.append({"texture": tex_path, "at": [x, y], "scale": scale})
|
|
614
|
+
out = Path(output_png)
|
|
615
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
616
|
+
canvas.save(out)
|
|
617
|
+
return {"ok": True, "preview": str(out), "placed": placed}
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def scene_to_project(
|
|
621
|
+
scene_json: str, project_path: str, scale: float = 0.0, prep: bool = True
|
|
622
|
+
) -> dict[str, Any]:
|
|
623
|
+
"""Import a composed scene JSON into a .spine project for editor polish.
|
|
624
|
+
prep=True (default) runs rig_editor_prep first so the editor importer
|
|
625
|
+
accepts generated or composed JSON, matching spine_import."""
|
|
626
|
+
return spine_import(scene_json, project_path, name="", scale=scale, prep=prep)
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Spine MCP server -- drive the Spine editor CLI and a JSON scene compositor
|
|
3
|
+
from any MCP client (Claude Code, Codex CLI, Cursor, ...).
|
|
4
|
+
|
|
5
|
+
Run (stdio): engine-mcp-spine (or: python -m engine_mcp.spine_server)
|
|
6
|
+
Self-test: engine-mcp-spine --selftest (lists tools, starts no server)
|
|
7
|
+
|
|
8
|
+
Env: SPINE_EXE (default C:\\Program Files\\Spine\\Spine.com), SPINE_UPDATE (pin editor version).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
19
|
+
except (AttributeError, ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
from mcp.server.fastmcp import FastMCP # noqa: E402
|
|
23
|
+
|
|
24
|
+
from engine_mcp import spine_core as core # noqa: E402
|
|
25
|
+
|
|
26
|
+
mcp = FastMCP("spine")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@mcp.tool()
|
|
30
|
+
def spine_version() -> dict[str, Any]:
|
|
31
|
+
"""Report Spine editor/launcher version, license state, and the CLI path in use."""
|
|
32
|
+
return core.spine_version()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def spine_export(
|
|
37
|
+
input_path: str, output_path: str, export: str = "json", clean: bool = False, version: str = ""
|
|
38
|
+
) -> dict[str, Any]:
|
|
39
|
+
"""Export a .spine project or skeleton data via the official Spine CLI.
|
|
40
|
+
`export`: shorthand 'json', 'binary', 'json+pack', 'binary+pack', OR a path to an
|
|
41
|
+
export-settings JSON saved from the editor's Export dialog (required for
|
|
42
|
+
PNG/GIF/APNG/video exports). `version` optionally pins the editor version."""
|
|
43
|
+
return core.spine_export(input_path, output_path, export, clean, version)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
def spine_import(
|
|
48
|
+
data_path: str, project_path: str, name: str = "", scale: float = 0.0, prep: bool = True
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
"""Import skeleton JSON or .skel data into a .spine project file (creates it).
|
|
51
|
+
`prep` (default true) first repairs generated JSON into editor-valid form
|
|
52
|
+
(rig_editor_prep); `scale` scales on import; `name` picks one skeleton."""
|
|
53
|
+
return core.spine_import(data_path, project_path, name, scale, prep)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def rig_editor_prep(rig_json: str, output: str = "") -> dict[str, Any]:
|
|
58
|
+
"""Repair generated rig JSON so the Spine editor imports it: weighted
|
|
59
|
+
meshes -> unweighted (editor re-binds natively), loose keyframe lists ->
|
|
60
|
+
valid Spine 4.x timelines. Writes <stem>_editor.json; non-destructive."""
|
|
61
|
+
return core.rig_editor_prep(rig_json, output)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool()
|
|
65
|
+
def spine_pack(
|
|
66
|
+
images_dir: str,
|
|
67
|
+
output_dir: str,
|
|
68
|
+
name: str = "atlas",
|
|
69
|
+
pack_settings: str = "",
|
|
70
|
+
project: str = "",
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Pack a folder of images into a Spine texture atlas (.atlas + PNG pages)."""
|
|
73
|
+
return core.spine_pack(images_dir, output_dir, name, pack_settings, project)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@mcp.tool()
|
|
77
|
+
def spine_unpack(atlas_path: str, images_dir: str, output_dir: str) -> dict[str, Any]:
|
|
78
|
+
"""Unpack a texture atlas back into individual images."""
|
|
79
|
+
return core.spine_unpack(atlas_path, images_dir, output_dir)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
def spine_clean(project_path: str, output_path: str = "") -> dict[str, Any]:
|
|
84
|
+
"""Run Spine's animation cleanup on a project (removes redundant keys)."""
|
|
85
|
+
return core.spine_clean(project_path, output_path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def spine_open_editor(project_path: str = "") -> dict[str, Any]:
|
|
90
|
+
"""Open the Spine editor GUI, optionally loading a project (non-blocking)."""
|
|
91
|
+
return core.spine_open_editor(project_path)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@mcp.tool()
|
|
95
|
+
def analyze_alpha(image: str) -> dict[str, Any]:
|
|
96
|
+
"""Inspect an image's alpha channel (transparent/opaque/partial
|
|
97
|
+
percentages) and rule whether it's already keyed or needs a cutout pass."""
|
|
98
|
+
return core.analyze_alpha(image)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool()
|
|
102
|
+
def scene_compose(
|
|
103
|
+
characters: list[dict[str, Any]], output_json: str, scene_name: str = "scene"
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""Merge N rigged characters into ONE Spine skeleton JSON (a scene).
|
|
106
|
+
characters: [{"json": rigPath, "prefix": name, "x": 0, "y": 0, "scale": 1.0,
|
|
107
|
+
"rotation": 0}, ...]. Bones/slots/skins/constraints/animations are prefixed
|
|
108
|
+
and grafted under per-character roots; weighted-mesh bone indices are re-based."""
|
|
109
|
+
return core.scene_compose(characters, output_json, scene_name)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@mcp.tool()
|
|
113
|
+
def scene_preview(
|
|
114
|
+
characters: list[dict[str, Any]], output_png: str, width: int = 1920, height: int = 1080
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Composite character textures at their scene placements -> layout preview PNG.
|
|
117
|
+
characters: [{"json" or "texture": path, "x": 0, "y": 0, "scale": 1.0}, ...]."""
|
|
118
|
+
return core.scene_preview(characters, output_png, width, height)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@mcp.tool()
|
|
122
|
+
def scene_to_project(scene_json: str, project_path: str, scale: float = 0.0) -> dict[str, Any]:
|
|
123
|
+
"""Import a composed scene JSON into a .spine project for polish in the editor."""
|
|
124
|
+
return core.scene_to_project(scene_json, project_path, scale)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _selftest() -> int:
|
|
128
|
+
import asyncio
|
|
129
|
+
|
|
130
|
+
tools = asyncio.run(mcp.list_tools())
|
|
131
|
+
print(f"[spine-mcp] {len(tools)} tools registered:")
|
|
132
|
+
for t in tools:
|
|
133
|
+
print(f" - {t.name}")
|
|
134
|
+
print(
|
|
135
|
+
f"[spine-mcp] SPINE_EXE = {core.SPINE_EXE} "
|
|
136
|
+
f"({'found' if Path(core.SPINE_EXE).exists() else 'MISSING'})"
|
|
137
|
+
)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main() -> int:
|
|
142
|
+
"""Console entry point: run the stdio server, or --selftest to list tools."""
|
|
143
|
+
if "--selftest" in sys.argv:
|
|
144
|
+
return _selftest()
|
|
145
|
+
mcp.run()
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: engine-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP tooling for game-engine work: a Spine 2D MCP server and a Godot MCP endpoint connector.
|
|
5
|
+
Author: Kurtlon Earl Sta. Ines
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kurtstaines/engine-mcp
|
|
8
|
+
Project-URL: Issues, https://github.com/kurtstaines/engine-mcp/issues
|
|
9
|
+
Keywords: mcp,model-context-protocol,spine,spine2d,godot,ai-agents,game-dev
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Games/Entertainment
|
|
18
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: mcp<2,>=1.2
|
|
23
|
+
Requires-Dist: pillow>=10
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# engine-mcp
|
|
30
|
+
|
|
31
|
+
[](https://github.com/kurtstaines/engine-mcp/actions/workflows/ci.yml)
|
|
32
|
+
[](https://github.com/kurtstaines/engine-mcp/blob/main/LICENSE)
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
**Let an AI agent drive the Spine 2D animation editor, and plug a Godot MCP endpoint into every AI client with one command.**
|
|
36
|
+
|
|
37
|
+
Game-engine tools are built for hands on a mouse. `engine-mcp` exposes them over the [Model Context Protocol](https://modelcontextprotocol.io) so an agent can export, import, pack, repair and compose skeletal animation assets itself, and get back a structured result it can branch on instead of a wall of console text.
|
|
38
|
+
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+
*A real client session against the server: connect, list tools, compose two characters into one scene, import it into a Spine project.*
|
|
42
|
+
|
|
43
|
+
Status: **v0.1, early.** Small on purpose.
|
|
44
|
+
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Spine MCP server**: 12 tools over stdio, usable from Claude Desktop, Claude Code, Cursor, Codex CLI or any other MCP client.
|
|
48
|
+
- **Editor lane**: wraps the official Spine command-line interface (export, import, texture pack and unpack, animation clean-up, open the editor).
|
|
49
|
+
- **Prep lane**: repairs generated skeleton JSON so the Spine 4.x importer accepts it. Converts weighted meshes to unweighted, loose keyframe lists to proper timelines, and 3.x Bezier curves to 4.x form. Pure Python, writes a new file, never touches the source.
|
|
50
|
+
- **Scene lane**: merges several rigged characters into one skeleton with prefixed names and re-based mesh bone indices, and renders a layout preview PNG. Pure Python.
|
|
51
|
+
- **Godot connector**: a small CLI that checks a Godot MCP addon's HTTP endpoint and registers it with Claude Code, Codex, Grok, Hermes and a project `.mcp.json`.
|
|
52
|
+
- **One result contract**: every tool returns a dict with an `ok` flag.
|
|
53
|
+
|
|
54
|
+
## Quickstart
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install engine-mcp
|
|
58
|
+
engine-mcp-spine --selftest
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
To run the examples, work from a clone instead:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
git clone https://github.com/kurtstaines/engine-mcp.git
|
|
65
|
+
cd engine-mcp
|
|
66
|
+
pip install -e .
|
|
67
|
+
python examples/compose_scene.py
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`--selftest` lists the registered tools and reports whether the Spine CLI was found. The example composes two characters into one scene with no Spine install needed.
|
|
71
|
+
|
|
72
|
+
<img src="https://raw.githubusercontent.com/kurtstaines/engine-mcp/main/docs/screenshots/sample-rig.gif" alt="The sample character playing its idle, wave and cheer animations" width="300">
|
|
73
|
+
|
|
74
|
+
*The sample rig's `idle`, `wave` and `cheer` animations, recorded playing in the Spine editor.*
|
|
75
|
+
|
|
76
|
+
### The sample rig
|
|
77
|
+
|
|
78
|
+
[examples/build_rig.py](https://github.com/kurtstaines/engine-mcp/blob/main/examples/build_rig.py) turns one flat character image into a Spine skeleton:
|
|
79
|
+
|
|
80
|
+
- keys out the background and cuts the art into six parts (body, head, upper arms, forearms)
|
|
81
|
+
- writes `examples/mecha.json` with seven bones and region attachments
|
|
82
|
+
- generates three animations as functions of time, with each part trailing the body a beat late so the idle loop breathes instead of bobbing
|
|
83
|
+
|
|
84
|
+
The cut lines and joint positions are hand-picked for this one image; it is an example, not a general auto-rigger. The source art was generated with Grok Imagine.
|
|
85
|
+
|
|
86
|
+

|
|
87
|
+
|
|
88
|
+
*`examples/mecha.json` imported into a Spine project with `spine_import` and opened with `spine_open_editor`: seven bones, six image parts and three animations.*
|
|
89
|
+
|
|
90
|
+
Requirements: Python 3.10+. The editor lane also needs a licensed [Spine](https://esotericsoftware.com) install; set `SPINE_EXE` if the CLI is not at `C:\Program Files\Spine\Spine.com`. The prep, scene and image tools work without it.
|
|
91
|
+
|
|
92
|
+
## Connect an MCP client
|
|
93
|
+
|
|
94
|
+
**Claude Code**
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
claude mcp add spine -- engine-mcp-spine
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Claude Desktop** (`claude_desktop_config.json`), **Cursor**, or a project **`.mcp.json`**:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"spine": {
|
|
106
|
+
"command": "engine-mcp-spine",
|
|
107
|
+
"env": { "SPINE_EXE": "C:/Program Files/Spine/Spine.com" }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
With [uv](https://docs.astral.sh/uv/) installed you can skip the install step and let the client fetch the package: set `"command": "uvx"` and `"args": ["--from", "engine-mcp", "engine-mcp-spine"]`.
|
|
114
|
+
|
|
115
|
+
If the client cannot find `engine-mcp-spine` on its `PATH` (common when you installed into a virtual environment), point `command` at that environment's Python and use `"args": ["-m", "engine_mcp.spine_server"]`. A copy of this config lives in [examples/mcp.json](https://github.com/kurtstaines/engine-mcp/blob/main/examples/mcp.json).
|
|
116
|
+
|
|
117
|
+
| Variable | Purpose |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `SPINE_EXE` | Path to the Spine CLI. Default `C:\Program Files\Spine\Spine.com`. |
|
|
120
|
+
| `SPINE_UPDATE` | Optional. Pins the editor version for every call, for example `4.2.43`. |
|
|
121
|
+
|
|
122
|
+
## Tools
|
|
123
|
+
|
|
124
|
+
| Tool | Inputs | What it does |
|
|
125
|
+
|---|---|---|
|
|
126
|
+
| `spine_version` | none | Reports the Spine version and the CLI path in use. |
|
|
127
|
+
| `spine_export` | `input_path`, `output_path`, `export="json"`, `clean=False`, `version=""` | Exports a project or skeleton data. `export` is `json`, `binary`, `json+pack`, `binary+pack`, or a path to an export-settings JSON saved from the editor. |
|
|
128
|
+
| `spine_import` | `data_path`, `project_path`, `name=""`, `scale=0.0`, `prep=True` | Imports skeleton JSON or `.skel` into a `.spine` project. Runs `rig_editor_prep` on JSON first unless `prep` is false. |
|
|
129
|
+
| `spine_pack` | `images_dir`, `output_dir`, `name="atlas"`, `pack_settings=""`, `project=""` | Packs a folder of images into a texture atlas. |
|
|
130
|
+
| `spine_unpack` | `atlas_path`, `images_dir`, `output_dir` | Unpacks a texture atlas back into images. |
|
|
131
|
+
| `spine_clean` | `project_path`, `output_path=""` | Runs Spine's animation clean-up. |
|
|
132
|
+
| `spine_open_editor` | `project_path=""` | Opens the Spine editor, optionally with a project. Does not block. Spine shows its launcher first and waits for **Start** unless "Start automatically" is ticked there. The editor closes when the MCP client disconnects. |
|
|
133
|
+
| `rig_editor_prep` | `rig_json`, `output=""` | Repairs skeleton JSON for the editor importer. Writes `<stem>_editor.json`. |
|
|
134
|
+
| `analyze_alpha` | `image` | Reports transparent, opaque and partial alpha percentages and whether the image is already keyed. |
|
|
135
|
+
| `scene_compose` | `characters`, `output_json`, `scene_name="scene"` | Merges characters into one skeleton. Each character is `{"json", "prefix", "x", "y", "scale", "rotation"}`. |
|
|
136
|
+
| `scene_preview` | `characters`, `output_png`, `width=1920`, `height=1080` | Composites each character's texture at its placement into a layout PNG. |
|
|
137
|
+
| `scene_to_project` | `scene_json`, `project_path`, `scale=0.0` | Imports a composed scene into a `.spine` project. |
|
|
138
|
+
|
|
139
|
+
### Godot connector
|
|
140
|
+
|
|
141
|
+
Needs a Godot project with an MCP addon that serves HTTP from the running editor. Defaults: endpoint `http://127.0.0.1:8000/mcp`, addon folder `addons/godot_ai`, server name `godot-ai`; override with `--url`, `--addon`, `--name`.
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
engine-mcp-godot --project path/to/godot_project doctor
|
|
145
|
+
engine-mcp-godot verify
|
|
146
|
+
engine-mcp-godot --clients claude,codex connect
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
| Command | What it does |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `status` | Shows whether the endpoint port is open and which clients are registered. |
|
|
152
|
+
| `doctor` | Reports addon install, endpoint liveness and client registration as three separate facts, because each fails for a different reason. |
|
|
153
|
+
| `verify` | Sends a real MCP `initialize` request and reports the response. |
|
|
154
|
+
| `connect` | Registers the endpoint with `claude`, `codex`, `grok`, `hermes` and/or `project` (`.mcp.json`). |
|
|
155
|
+
|
|
156
|
+
## Architecture
|
|
157
|
+
|
|
158
|
+
```mermaid
|
|
159
|
+
flowchart LR
|
|
160
|
+
client["MCP client<br/>Claude Desktop, Claude Code, Cursor"]
|
|
161
|
+
server["spine_server.py<br/>FastMCP, 12 tools"]
|
|
162
|
+
core["spine_core.py"]
|
|
163
|
+
cli["Spine CLI"]
|
|
164
|
+
json["Skeleton JSON<br/>prep and scene lanes"]
|
|
165
|
+
png["Pillow<br/>alpha check, preview"]
|
|
166
|
+
connector["godot_connect.py<br/>engine-mcp-godot"]
|
|
167
|
+
godot["Godot editor<br/>MCP addon over HTTP"]
|
|
168
|
+
configs["Client configs<br/>Claude, Codex, Grok, Hermes, .mcp.json"]
|
|
169
|
+
|
|
170
|
+
client -- stdio --> server --> core
|
|
171
|
+
core -- subprocess --> cli
|
|
172
|
+
core --> json
|
|
173
|
+
core --> png
|
|
174
|
+
connector -- "initialize probe" --> godot
|
|
175
|
+
connector -- registers --> configs
|
|
176
|
+
client -. "HTTP, after connect" .-> godot
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The server is a thin layer: each tool is one call into `spine_core`, which you can also import directly from a script (see [examples/compose_scene.py](https://github.com/kurtstaines/engine-mcp/blob/main/examples/compose_scene.py)).
|
|
180
|
+
|
|
181
|
+
## Tests
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
pip install -e ".[dev]"
|
|
185
|
+
pytest
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
24 tests cover the prep, scene and image lanes end to end on a synthetic rig, the MCP tool registration and a tool call round trip, and the Godot connector against a local stand-in HTTP endpoint. The editor lane is tested at the level of the arguments it passes to the Spine CLI; CI does not have Spine installed, so the CLI itself is not exercised there. Registration through the `claude` and `codex` CLIs is not covered.
|
|
189
|
+
|
|
190
|
+
## Roadmap
|
|
191
|
+
|
|
192
|
+
- Command-line front door for the Spine tools, for agents without an MCP client
|
|
193
|
+
- Engine registry and status server (which engine adapters are installed and live)
|
|
194
|
+
- Bone-marker tool: draw markers on art and save them as rig presets
|
|
195
|
+
- Automatic rigging lane (image to rigged skeleton)
|
|
196
|
+
- 3D animation workflow for Godot driven through MCP
|
|
197
|
+
|
|
198
|
+
## Contributing
|
|
199
|
+
|
|
200
|
+
Issues and pull requests are welcome. Before opening a PR:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
ruff check . && ruff format --check . && pytest
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Keep tools returning the `{"ok": bool, ...}` contract, and add a test for anything that does not need the Spine editor to run.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT. See [LICENSE](https://github.com/kurtstaines/engine-mcp/blob/main/LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
engine_mcp/__init__.py,sha256=k74wTEqpgQWCzONslkHnwWg6OC1PZOalckrtecsgADg,110
|
|
2
|
+
engine_mcp/godot_connect.py,sha256=JZjQSWFNkujIrXfRvJ-5XC5-s_xyG_BbCKGqLUV8j04,10791
|
|
3
|
+
engine_mcp/spine_core.py,sha256=nn7x0kYOp3isIuipy4yVJsuGemQBIaT4a4tbOi_06WI,23469
|
|
4
|
+
engine_mcp/spine_server.py,sha256=z8yw01e61-jO4hwjT_OwUt9J_5FwTXe_rc6aYBkJGA4,5314
|
|
5
|
+
engine_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=UhThRTv_qeZz8iSxoTXbUldXEWE1P-gUfB9qLfVU_8Q,1079
|
|
6
|
+
engine_mcp-0.1.0.dist-info/METADATA,sha256=6XfeQauzeqA2VOuNDQbHQtMyu1iGD6oE17udHGS844U,11178
|
|
7
|
+
engine_mcp-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
8
|
+
engine_mcp-0.1.0.dist-info/entry_points.txt,sha256=mRzMOCxRPPh4r6cah-HCgrHcHN6r4YqCkGWVpvdeZB0,115
|
|
9
|
+
engine_mcp-0.1.0.dist-info/top_level.txt,sha256=W9MfJFVplVrgJ-nFhO4UwpiafQjLUGk-ba95f7Rl2pw,11
|
|
10
|
+
engine_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kurtlon Earl Sta. Ines
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
engine_mcp
|