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 ADDED
@@ -0,0 +1,3 @@
1
+ """engine-mcp: MCP tooling for game-engine work (Spine 2D server, Godot connector)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())
@@ -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
+ [![CI](https://github.com/kurtstaines/engine-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/kurtstaines/engine-mcp/actions/workflows/ci.yml)
32
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/kurtstaines/engine-mcp/blob/main/LICENSE)
33
+ ![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)
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
+ ![A real MCP client session: listing the 12 tools, composing a scene and importing it into a Spine project](https://raw.githubusercontent.com/kurtstaines/engine-mcp/main/docs/screenshots/demo-terminal.gif)
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
+ ![The sample rig open in the Spine editor](https://raw.githubusercontent.com/kurtstaines/engine-mcp/main/docs/screenshots/spine-editor.jpg)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ engine-mcp-godot = engine_mcp.godot_connect:main
3
+ engine-mcp-spine = engine_mcp.spine_server:main
@@ -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