spritehatch-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- spritehatch_mcp-0.1.0/LICENSE +21 -0
- spritehatch_mcp-0.1.0/PKG-INFO +122 -0
- spritehatch_mcp-0.1.0/README.md +100 -0
- spritehatch_mcp-0.1.0/pyproject.toml +33 -0
- spritehatch_mcp-0.1.0/setup.cfg +4 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/__init__.py +2 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/__main__.py +26 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/api.py +84 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/files.py +185 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/guards.py +139 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/guide.py +411 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/previews.py +118 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp/server.py +1335 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp.egg-info/PKG-INFO +122 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp.egg-info/SOURCES.txt +17 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp.egg-info/dependency_links.txt +1 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp.egg-info/entry_points.txt +2 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp.egg-info/requires.txt +2 -0
- spritehatch_mcp-0.1.0/spritehatch_mcp.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SpriteHatch
|
|
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,122 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spritehatch-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for SpriteHatch: AI agents generate 2D game sprites, animations, effects, items, UI, scenes and tilesets and export them for Godot, Unity, GameMaker and RPG Maker.
|
|
5
|
+
Author: SpriteHatch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://spritehatch.com
|
|
8
|
+
Project-URL: Documentation, https://spritehatch.com/docs/mcp
|
|
9
|
+
Keywords: mcp,model-context-protocol,ai-agents,game-development,sprites,pixel-art,animation,gamedev
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Games/Entertainment
|
|
15
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
License-File: LICENSE
|
|
19
|
+
Requires-Dist: mcp<3,>=2.3
|
|
20
|
+
Requires-Dist: pillow>=10
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# SpriteHatch MCP
|
|
24
|
+
|
|
25
|
+
Lets AI agents (Claude Code, Cursor, Codex, Windsurf…) make 2D game assets with [SpriteHatch](https://spritehatch.com)
|
|
26
|
+
and bring them into the game: characters, monsters, animations, abilities with separate effect layers, effects, items,
|
|
27
|
+
UI, parallax scenes and tilesets, exported for Godot, Unity, GameMaker, RPG Maker or plain sprite sheets.
|
|
28
|
+
|
|
29
|
+
The server teaches the agent which tool fits each need and guards the user's credits:
|
|
30
|
+
|
|
31
|
+
- `spritehatch_guide` (free) turns a game need ("the dragon breathes fire at the player") into the right recipe, with
|
|
32
|
+
costs and pitfalls. The same decision rules are sent to the agent when it connects.
|
|
33
|
+
- Quality, art style and image engine are the user's choice: when the request does not say, the tool returns the
|
|
34
|
+
options (with costs) for the agent to ask, or shows a form in clients that support it. Image engines are known only by
|
|
35
|
+
their product names.
|
|
36
|
+
- Effects that stay on the body (a charge-up) are drawn into the character animation; what flies to a target becomes
|
|
37
|
+
separate effects the game moves. The animation plan is shown exactly as it will be generated (prompt, ending, beats)
|
|
38
|
+
and can be corrected before paying.
|
|
39
|
+
- Session budget and approval: spending above the approval limit since the last approval asks the user (MCP elicitation,
|
|
40
|
+
or a one-time `confirm_token` the agent only sends after asking), so splitting a request does not get around it. A
|
|
41
|
+
prompt the client declines on its own is not taken as the user's answer. Failed jobs are refunded and give the budget
|
|
42
|
+
back; the same request failing twice stops.
|
|
43
|
+
- Effects are checked for frames cut by the canvas edge and empty frames; a bad ending can be redone from a frame.
|
|
44
|
+
- Animations are judged for free after generation; the agent may improve one at most once on its own, then shows the
|
|
45
|
+
user.
|
|
46
|
+
- Results come back as preview images, and exports come with a manifest (frame size, pivot, clips, ability timeline)
|
|
47
|
+
the agent can wire into code.
|
|
48
|
+
|
|
49
|
+
Requires a SpriteHatch API key (Pro or Studio plan): app → avatar → **My account and statement** → **API keys**.
|
|
50
|
+
|
|
51
|
+
## Local (stdio): files go straight into your game project
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
# Claude Code
|
|
55
|
+
claude mcp add spritehatch -e SPRITEHATCH_API_KEY=sg_live_... -- uvx spritehatch-mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Other clients (Cursor, Windsurf, Codex…), in their MCP config:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"spritehatch": {
|
|
64
|
+
"command": "uvx",
|
|
65
|
+
"args": ["spritehatch-mcp"],
|
|
66
|
+
"env": { "SPRITEHATCH_API_KEY": "sg_live_..." }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Variable | Default | |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `SPRITEHATCH_API_KEY` | — | Required. |
|
|
75
|
+
| `SPRITEHATCH_BUDGET` | `300` | Credits this session may spend. |
|
|
76
|
+
| `SPRITEHATCH_CONFIRM_ABOVE` | `60` | Single requests above this need the user's approval. `0` = always ask. |
|
|
77
|
+
| `SPRITEHATCH_EXPORT_DIR` | `spritehatch-assets` | Where `export_asset` writes when no folder is given. |
|
|
78
|
+
| `SPRITEHATCH_URL` | `https://spritehatch.com` | API address. |
|
|
79
|
+
|
|
80
|
+
## Hosted (streamable HTTP): nothing to install
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
claude mcp add --transport http spritehatch https://spritehatch.com/mcp \
|
|
84
|
+
--header "Authorization: Bearer sg_live_..." --header "X-SpriteHatch-Budget: 300"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Optional headers: `X-SpriteHatch-Budget`, `X-SpriteHatch-Confirm-Above`. The hosted server cannot read or write the
|
|
88
|
+
agent's disk: reference images go as https URLs or base64, and exports come back as 30-minute download links.
|
|
89
|
+
|
|
90
|
+
Run it yourself: `spritehatch-mcp --http --host 0.0.0.0 --port 7870` with `SPRITEHATCH_URL` pointing at the API and
|
|
91
|
+
`SPRITEHATCH_MCP_PUBLIC_URL` set to this server's public address (for the download links). Health check: `GET /health`.
|
|
92
|
+
|
|
93
|
+
## Tools
|
|
94
|
+
|
|
95
|
+
| Need | Tool |
|
|
96
|
+
|---|---|
|
|
97
|
+
| Not sure what to make | `spritehatch_guide` (free) |
|
|
98
|
+
| Account, credits, session budget | `get_account` |
|
|
99
|
+
| Projects and style kit (pixel art, height, 2/4/8 directions, palette) | `list_projects`, `setup_project` |
|
|
100
|
+
| What already exists | `list_assets`, `get_asset`, `get_preview` |
|
|
101
|
+
| New character/monster, from text or an image | `create_character`, `choose_candidate` |
|
|
102
|
+
| Outfit, color, armor, elite version, rarity | `add_skin` |
|
|
103
|
+
| Top-down / isometric views | `add_directions` |
|
|
104
|
+
| Body animation (effects on the body included) | `plan_animation` (free) → `generate_animation`, `review_animation` (free), `improve_animation` |
|
|
105
|
+
| Attack or power that emits something | charge animation + `create_vfx` (best control), or `create_ability` (automatic) |
|
|
106
|
+
| Standalone effect, projectile, impact | `create_vfx`, `improve_vfx` |
|
|
107
|
+
| Pick which kept version is used | `use_version` |
|
|
108
|
+
| Item, weapon, prop, icon | `create_item` |
|
|
109
|
+
| Buttons, bars, panels | `create_ui`, `add_ui_states` |
|
|
110
|
+
| Side-scroller background | `create_scene`, `add_parallax_layers` |
|
|
111
|
+
| Tilesets / maps | `create_tileset` |
|
|
112
|
+
| Fix a detail | `edit_asset`, `apply_edit` |
|
|
113
|
+
| Wait for generation | `wait_for_jobs` |
|
|
114
|
+
| Take it to the game | `export_asset` |
|
|
115
|
+
|
|
116
|
+
Resource `spritehatch://guide` has the full guide; prompts `make_game_character` and `add_attack_with_effect` start the
|
|
117
|
+
two most common flows.
|
|
118
|
+
|
|
119
|
+
## Development
|
|
120
|
+
|
|
121
|
+
Tests live in the main repository: `.venv/bin/python scripts/check_mcp.py` runs the real API, queue, billing and
|
|
122
|
+
exports with a simulated GPU, through both transports.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SpriteHatch MCP
|
|
2
|
+
|
|
3
|
+
Lets AI agents (Claude Code, Cursor, Codex, Windsurf…) make 2D game assets with [SpriteHatch](https://spritehatch.com)
|
|
4
|
+
and bring them into the game: characters, monsters, animations, abilities with separate effect layers, effects, items,
|
|
5
|
+
UI, parallax scenes and tilesets, exported for Godot, Unity, GameMaker, RPG Maker or plain sprite sheets.
|
|
6
|
+
|
|
7
|
+
The server teaches the agent which tool fits each need and guards the user's credits:
|
|
8
|
+
|
|
9
|
+
- `spritehatch_guide` (free) turns a game need ("the dragon breathes fire at the player") into the right recipe, with
|
|
10
|
+
costs and pitfalls. The same decision rules are sent to the agent when it connects.
|
|
11
|
+
- Quality, art style and image engine are the user's choice: when the request does not say, the tool returns the
|
|
12
|
+
options (with costs) for the agent to ask, or shows a form in clients that support it. Image engines are known only by
|
|
13
|
+
their product names.
|
|
14
|
+
- Effects that stay on the body (a charge-up) are drawn into the character animation; what flies to a target becomes
|
|
15
|
+
separate effects the game moves. The animation plan is shown exactly as it will be generated (prompt, ending, beats)
|
|
16
|
+
and can be corrected before paying.
|
|
17
|
+
- Session budget and approval: spending above the approval limit since the last approval asks the user (MCP elicitation,
|
|
18
|
+
or a one-time `confirm_token` the agent only sends after asking), so splitting a request does not get around it. A
|
|
19
|
+
prompt the client declines on its own is not taken as the user's answer. Failed jobs are refunded and give the budget
|
|
20
|
+
back; the same request failing twice stops.
|
|
21
|
+
- Effects are checked for frames cut by the canvas edge and empty frames; a bad ending can be redone from a frame.
|
|
22
|
+
- Animations are judged for free after generation; the agent may improve one at most once on its own, then shows the
|
|
23
|
+
user.
|
|
24
|
+
- Results come back as preview images, and exports come with a manifest (frame size, pivot, clips, ability timeline)
|
|
25
|
+
the agent can wire into code.
|
|
26
|
+
|
|
27
|
+
Requires a SpriteHatch API key (Pro or Studio plan): app → avatar → **My account and statement** → **API keys**.
|
|
28
|
+
|
|
29
|
+
## Local (stdio): files go straight into your game project
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# Claude Code
|
|
33
|
+
claude mcp add spritehatch -e SPRITEHATCH_API_KEY=sg_live_... -- uvx spritehatch-mcp
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Other clients (Cursor, Windsurf, Codex…), in their MCP config:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"mcpServers": {
|
|
41
|
+
"spritehatch": {
|
|
42
|
+
"command": "uvx",
|
|
43
|
+
"args": ["spritehatch-mcp"],
|
|
44
|
+
"env": { "SPRITEHATCH_API_KEY": "sg_live_..." }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
| Variable | Default | |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| `SPRITEHATCH_API_KEY` | — | Required. |
|
|
53
|
+
| `SPRITEHATCH_BUDGET` | `300` | Credits this session may spend. |
|
|
54
|
+
| `SPRITEHATCH_CONFIRM_ABOVE` | `60` | Single requests above this need the user's approval. `0` = always ask. |
|
|
55
|
+
| `SPRITEHATCH_EXPORT_DIR` | `spritehatch-assets` | Where `export_asset` writes when no folder is given. |
|
|
56
|
+
| `SPRITEHATCH_URL` | `https://spritehatch.com` | API address. |
|
|
57
|
+
|
|
58
|
+
## Hosted (streamable HTTP): nothing to install
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
claude mcp add --transport http spritehatch https://spritehatch.com/mcp \
|
|
62
|
+
--header "Authorization: Bearer sg_live_..." --header "X-SpriteHatch-Budget: 300"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Optional headers: `X-SpriteHatch-Budget`, `X-SpriteHatch-Confirm-Above`. The hosted server cannot read or write the
|
|
66
|
+
agent's disk: reference images go as https URLs or base64, and exports come back as 30-minute download links.
|
|
67
|
+
|
|
68
|
+
Run it yourself: `spritehatch-mcp --http --host 0.0.0.0 --port 7870` with `SPRITEHATCH_URL` pointing at the API and
|
|
69
|
+
`SPRITEHATCH_MCP_PUBLIC_URL` set to this server's public address (for the download links). Health check: `GET /health`.
|
|
70
|
+
|
|
71
|
+
## Tools
|
|
72
|
+
|
|
73
|
+
| Need | Tool |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Not sure what to make | `spritehatch_guide` (free) |
|
|
76
|
+
| Account, credits, session budget | `get_account` |
|
|
77
|
+
| Projects and style kit (pixel art, height, 2/4/8 directions, palette) | `list_projects`, `setup_project` |
|
|
78
|
+
| What already exists | `list_assets`, `get_asset`, `get_preview` |
|
|
79
|
+
| New character/monster, from text or an image | `create_character`, `choose_candidate` |
|
|
80
|
+
| Outfit, color, armor, elite version, rarity | `add_skin` |
|
|
81
|
+
| Top-down / isometric views | `add_directions` |
|
|
82
|
+
| Body animation (effects on the body included) | `plan_animation` (free) → `generate_animation`, `review_animation` (free), `improve_animation` |
|
|
83
|
+
| Attack or power that emits something | charge animation + `create_vfx` (best control), or `create_ability` (automatic) |
|
|
84
|
+
| Standalone effect, projectile, impact | `create_vfx`, `improve_vfx` |
|
|
85
|
+
| Pick which kept version is used | `use_version` |
|
|
86
|
+
| Item, weapon, prop, icon | `create_item` |
|
|
87
|
+
| Buttons, bars, panels | `create_ui`, `add_ui_states` |
|
|
88
|
+
| Side-scroller background | `create_scene`, `add_parallax_layers` |
|
|
89
|
+
| Tilesets / maps | `create_tileset` |
|
|
90
|
+
| Fix a detail | `edit_asset`, `apply_edit` |
|
|
91
|
+
| Wait for generation | `wait_for_jobs` |
|
|
92
|
+
| Take it to the game | `export_asset` |
|
|
93
|
+
|
|
94
|
+
Resource `spritehatch://guide` has the full guide; prompts `make_game_character` and `add_attack_with_effect` start the
|
|
95
|
+
two most common flows.
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
Tests live in the main repository: `.venv/bin/python scripts/check_mcp.py` runs the real API, queue, billing and
|
|
100
|
+
exports with a simulated GPU, through both transports.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spritehatch-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for SpriteHatch: AI agents generate 2D game sprites, animations, effects, items, UI, scenes and tilesets and export them for Godot, Unity, GameMaker and RPG Maker."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "SpriteHatch" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = ["mcp>=2.3,<3", "pillow>=10"]
|
|
15
|
+
keywords = ["mcp", "model-context-protocol", "ai-agents", "game-development", "sprites", "pixel-art", "animation", "gamedev"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Games/Entertainment",
|
|
22
|
+
"Topic :: Multimedia :: Graphics",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://spritehatch.com"
|
|
27
|
+
Documentation = "https://spritehatch.com/docs/mcp"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
spritehatch-mcp = "spritehatch_mcp.__main__:main"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools]
|
|
33
|
+
packages = ["spritehatch_mcp"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""spritehatch-mcp → local server over stdio (Claude Code, Cursor, Codex…); needs SPRITEHATCH_API_KEY.
|
|
2
|
+
spritehatch-mcp --http → hosted server (streamable HTTP); each request brings its key in the Authorization header."""
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from .server import Config, build
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
parser = argparse.ArgumentParser(prog='spritehatch-mcp', description='SpriteHatch MCP server')
|
|
11
|
+
parser.add_argument('--http', action='store_true', help='serve over streamable HTTP (hosted mode) instead of stdio')
|
|
12
|
+
parser.add_argument('--host', default=os.environ.get('SPRITEHATCH_MCP_HOST', '127.0.0.1'))
|
|
13
|
+
parser.add_argument('--port', type=int, default=int(os.environ.get('SPRITEHATCH_MCP_PORT', '7870')))
|
|
14
|
+
args = parser.parse_args()
|
|
15
|
+
if args.http:
|
|
16
|
+
config = Config.from_env('hosted')
|
|
17
|
+
build(config).run('streamable-http', host=args.host, port=args.port, streamable_http_path='/mcp')
|
|
18
|
+
else:
|
|
19
|
+
config = Config.from_env('local')
|
|
20
|
+
if not config.api_key:
|
|
21
|
+
parser.exit(2, 'SPRITEHATCH_API_KEY is not set. Create a key in SpriteHatch > My account > API keys.\n')
|
|
22
|
+
build(config).run('stdio')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == '__main__':
|
|
26
|
+
main()
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Async client for the SpriteHatch HTTP API (the same API documented at /docs/api).
|
|
2
|
+
|
|
3
|
+
Every call carries the customer's API key (`Authorization: Bearer sg_live_…`), so ownership, plan limits (API = Pro and
|
|
4
|
+
Studio) and credit charging stay where they already are: in the SpriteHatch server. This module only translates HTTP
|
|
5
|
+
failures into errors an agent can act on.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import httpx2 as httpx # the HTTP client the MCP SDK already ships (httpx fork)
|
|
10
|
+
|
|
11
|
+
# HTTP status → stable code + what the agent should do. The server's `detail` text is still in Portuguese.
|
|
12
|
+
ERRORS = {
|
|
13
|
+
0: ('unreachable', 'Could not reach SpriteHatch. Check SPRITEHATCH_URL and the network, then try again.'),
|
|
14
|
+
400: ('invalid_request', 'The request was rejected; fix the arguments described in `detail`.'),
|
|
15
|
+
401: ('invalid_api_key', 'The API key is missing, wrong or deleted. The user creates one in SpriteHatch > My account > '
|
|
16
|
+
'API keys and sets SPRITEHATCH_API_KEY (local) or the Authorization header (hosted).'),
|
|
17
|
+
402: ('insufficient_credits', 'Not enough credits. Tell the user the cost and balance; do not retry.'),
|
|
18
|
+
403: ('not_allowed', 'The plan does not include the API/MCP (Pro and Studio do), or this action needs the web app.'),
|
|
19
|
+
404: ('not_found', 'The asset, project or job does not exist or belongs to another account. Use list_assets / list_projects.'),
|
|
20
|
+
409: ('busy_or_changed', 'The asset is not ready yet, another generation on the same animation is running, or the '
|
|
21
|
+
'version changed. Call wait_for_jobs, then try again.'),
|
|
22
|
+
422: ('invalid_request', 'Some argument has the wrong type or is out of range.'),
|
|
23
|
+
502: ('upstream_error', 'A payment/upstream service failed. Try again later.'),
|
|
24
|
+
504: ('timeout', 'SpriteHatch took too long to answer. Try again; generation jobs keep running on the server.'),
|
|
25
|
+
503: ('service_unavailable', 'A SpriteHatch service (vision planner or judge) is temporarily unavailable. Try again later.'),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiError(Exception):
|
|
30
|
+
def __init__(self, status: int, detail: str):
|
|
31
|
+
self.status = status
|
|
32
|
+
self.detail = detail
|
|
33
|
+
self.code, self.hint = ERRORS.get(status, ('server_error', 'Unexpected server error. Try again later.'))
|
|
34
|
+
super().__init__(f'{self.code} ({status}): {detail}')
|
|
35
|
+
|
|
36
|
+
def as_dict(self) -> dict:
|
|
37
|
+
return {'error': self.code, 'status': self.status, 'detail': self.detail, 'what_to_do': self.hint}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Api:
|
|
41
|
+
def __init__(self, base_url: str, key: str, timeout: float = 60.0):
|
|
42
|
+
if not key:
|
|
43
|
+
raise ApiError(401, 'Nenhuma chave de API configurada.')
|
|
44
|
+
self.base_url = base_url.rstrip('/')
|
|
45
|
+
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, follow_redirects=True,
|
|
46
|
+
headers={'Authorization': f'Bearer {key}', 'User-Agent': 'spritehatch-mcp'})
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> Api:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, *_) -> None:
|
|
52
|
+
await self._client.aclose()
|
|
53
|
+
|
|
54
|
+
async def _send(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
55
|
+
try:
|
|
56
|
+
response = await self._client.request(method, path, **kwargs)
|
|
57
|
+
except httpx.TimeoutException:
|
|
58
|
+
raise ApiError(504, 'O servidor demorou para responder.')
|
|
59
|
+
except httpx.HTTPError as error:
|
|
60
|
+
raise ApiError(0, f'Não foi possível falar com o SpriteHatch em {self.base_url}: {error}')
|
|
61
|
+
if response.status_code >= 400:
|
|
62
|
+
try:
|
|
63
|
+
detail = response.json().get('detail')
|
|
64
|
+
except ValueError:
|
|
65
|
+
detail = None
|
|
66
|
+
if not isinstance(detail, str):
|
|
67
|
+
detail = str(detail or response.text[:300] or response.reason_phrase)
|
|
68
|
+
raise ApiError(response.status_code, detail)
|
|
69
|
+
return response
|
|
70
|
+
|
|
71
|
+
async def get(self, path: str, **params):
|
|
72
|
+
return (await self._send('GET', path, params={k: v for k, v in params.items() if v is not None})).json()
|
|
73
|
+
|
|
74
|
+
async def post(self, path: str, body: dict | None = None, *, data: dict | None = None, files: dict | None = None):
|
|
75
|
+
if files is not None:
|
|
76
|
+
return (await self._send('POST', path, data=data or {}, files=files)).json()
|
|
77
|
+
return (await self._send('POST', path, json=body or {})).json()
|
|
78
|
+
|
|
79
|
+
async def patch(self, path: str, body: dict):
|
|
80
|
+
return (await self._send('PATCH', path, json=body)).json()
|
|
81
|
+
|
|
82
|
+
async def get_bytes(self, path: str, **params) -> tuple[bytes, httpx.Headers]:
|
|
83
|
+
response = await self._send('GET', path, params={k: v for k, v in params.items() if v is not None})
|
|
84
|
+
return response.content, response.headers
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Files in and out: reference images the agent passes, and exports it takes home.
|
|
2
|
+
|
|
3
|
+
Local (stdio) mode reads images from disk and unpacks exports into the game project. Hosted mode cannot touch the
|
|
4
|
+
agent's disk, so it accepts URLs/base64 and serves each export from a short-lived link.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
import io
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import socket
|
|
17
|
+
import time
|
|
18
|
+
import zipfile
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from urllib.parse import urlparse
|
|
21
|
+
|
|
22
|
+
import httpx2 as httpx # the HTTP client the MCP SDK already ships (httpx fork)
|
|
23
|
+
|
|
24
|
+
MAX_IMAGE = 15 * 1024 * 1024
|
|
25
|
+
LINK_TTL = 30 * 60
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def load_image(ref: str, allow_paths: bool) -> tuple[str, bytes]:
|
|
29
|
+
"""`ref` is a local path (local mode only), an http(s) URL, a data URL or raw base64 → (filename, bytes)."""
|
|
30
|
+
ref = ref.strip()
|
|
31
|
+
if ref.startswith(('http://', 'https://')):
|
|
32
|
+
if not allow_paths: # hosted: never fetch the server's own network (SSRF); redirects are checked hop by hop
|
|
33
|
+
await _public_only(ref)
|
|
34
|
+
async with httpx.AsyncClient(timeout=30, follow_redirects=allow_paths) as client:
|
|
35
|
+
response = await client.get(ref)
|
|
36
|
+
hops = 0
|
|
37
|
+
while not allow_paths and response.is_redirect and hops < 3:
|
|
38
|
+
ref = str(response.next_request.url) if response.next_request else ''
|
|
39
|
+
await _public_only(ref)
|
|
40
|
+
response = await client.get(ref)
|
|
41
|
+
hops += 1
|
|
42
|
+
if response.status_code >= 400:
|
|
43
|
+
raise ValueError(f'Could not download the reference image ({response.status_code}).')
|
|
44
|
+
data, name = response.content, Path(ref.split('?')[0]).name or 'reference.png'
|
|
45
|
+
elif ref.startswith('data:'):
|
|
46
|
+
data, name = _b64(ref.split(',', 1)[-1]), 'reference.png'
|
|
47
|
+
elif allow_paths and (Path(ref).expanduser().exists()):
|
|
48
|
+
path = Path(ref).expanduser()
|
|
49
|
+
data, name = path.read_bytes(), path.name
|
|
50
|
+
elif re.fullmatch(r'[A-Za-z0-9+/=\s]{200,}', ref):
|
|
51
|
+
data, name = _b64(ref), 'reference.png'
|
|
52
|
+
elif allow_paths:
|
|
53
|
+
raise ValueError(f'Reference image not found: {ref}')
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError('The hosted server cannot read local files: pass the image as an https URL or base64/data URL.')
|
|
56
|
+
if len(data) > MAX_IMAGE:
|
|
57
|
+
raise ValueError('The reference image is larger than 15 MB.')
|
|
58
|
+
return name, data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def _public_only(url: str) -> None:
|
|
62
|
+
host = urlparse(url).hostname
|
|
63
|
+
if not host:
|
|
64
|
+
raise ValueError('Invalid image URL.')
|
|
65
|
+
try:
|
|
66
|
+
infos = await asyncio.get_running_loop().getaddrinfo(host, None)
|
|
67
|
+
except socket.gaierror:
|
|
68
|
+
raise ValueError(f'Could not resolve {host}.')
|
|
69
|
+
for info in infos:
|
|
70
|
+
address = ipaddress.ip_address(info[4][0])
|
|
71
|
+
if not address.is_global:
|
|
72
|
+
raise ValueError('The image URL must be a public address.')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _b64(text: str) -> bytes:
|
|
76
|
+
try:
|
|
77
|
+
return base64.b64decode(re.sub(r'\s+', '', text), validate=True)
|
|
78
|
+
except (binascii.Error, ValueError):
|
|
79
|
+
raise ValueError('The reference image is not valid base64.')
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def manifest(zip_bytes: bytes, target: str) -> dict:
|
|
83
|
+
"""What the agent needs to wire the export into code, read from the files inside the zip."""
|
|
84
|
+
out: dict = {'files': [], 'clips': {}, 'notes': []}
|
|
85
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
|
86
|
+
names = [n for n in archive.namelist() if not n.endswith('/')]
|
|
87
|
+
out['files'] = names[:60] + ([f'... {len(names) - 60} more'] if len(names) > 60 else [])
|
|
88
|
+
for name in names:
|
|
89
|
+
if not name.endswith('.json'):
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(archive.read(name))
|
|
93
|
+
except ValueError:
|
|
94
|
+
continue
|
|
95
|
+
if not isinstance(data, dict):
|
|
96
|
+
continue
|
|
97
|
+
meta = data.get('meta') if isinstance(data.get('meta'), dict) else None
|
|
98
|
+
if meta and 'clips' in meta: # character atlas (TexturePacker hash + clips)
|
|
99
|
+
out['atlas'] = name
|
|
100
|
+
out['image'] = meta.get('image')
|
|
101
|
+
out['cell'] = meta.get('cell')
|
|
102
|
+
out['pixel_art'] = bool(meta.get('pixel'))
|
|
103
|
+
out['clips'] = {k: {f: v.get(f) for f in ('animation', 'direction', 'fps', 'loop', 'frames')}
|
|
104
|
+
for k, v in meta['clips'].items()}
|
|
105
|
+
elif str(data.get('schema', '')).startswith('sprite-ability'):
|
|
106
|
+
out['ability'] = {k: data.get(k) for k in (
|
|
107
|
+
'schema', 'name', 'mode', 'gesture', 'effects', 'anchors', 'release', 'speed', 'speed_units', 'effect_scale',
|
|
108
|
+
'effect_scale_units', 'emission_lead_ms', 'emission_fade_ms', 'projectile_grow_ms', 'stream_duration_ms',
|
|
109
|
+
'projectile_facing', 'anchor_space', 'target_height', 'cell') if data.get(k) is not None}
|
|
110
|
+
out['ability_file'] = name
|
|
111
|
+
elif 'frames' in data and 'blend' in data: # standalone effect
|
|
112
|
+
out['effect'] = {k: data.get(k) for k in ('name', 'frame_size', 'fps', 'loop', 'blend', 'pivot')}
|
|
113
|
+
out['effect']['frame_count'] = len(data.get('frames') or [])
|
|
114
|
+
elif name.endswith('tiles.json') or 'tiles' in data:
|
|
115
|
+
out['tileset'] = {k: data.get(k) for k in ('tile', 'tile_size', 'mode', 'columns', 'order', 'bits') if k in data}
|
|
116
|
+
elif 'layers' in data:
|
|
117
|
+
out['scene'] = {'layers': data.get('layers')}
|
|
118
|
+
for doc in ('COMPOSITION.txt', 'README.txt', 'STREAM.txt'):
|
|
119
|
+
hit = next((n for n in names if n.endswith(doc)), None)
|
|
120
|
+
if hit:
|
|
121
|
+
out['notes'].append({'file': hit, 'text': archive.read(hit).decode('utf-8', 'replace')[:1800]})
|
|
122
|
+
out['how_to_use'] = HOW_TO.get(target, HOW_TO['atlas'])
|
|
123
|
+
if 'ability' in out:
|
|
124
|
+
out['how_to_use'] += ' ' + HOW_TO['ability']
|
|
125
|
+
return out
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
HOW_TO = {
|
|
129
|
+
'godot': 'Godot 4: the .tres is a SpriteFrames resource; assign it to an AnimatedSprite2D and play("<animation>_<direction>"). '
|
|
130
|
+
'Offset the sprite so the pivot (cell.pivot, at the feet) sits on the node origin.',
|
|
131
|
+
'unity': 'Unity: copy the folder into Assets/. The Editor/SpriteGeneratorImporter.cs script slices the sheet with the foot '
|
|
132
|
+
'pivot and creates one AnimationClip per row (looping clips have loopTime on). Point filtering for pixel art.',
|
|
133
|
+
'gamemaker': 'GameMaker: import each spr_*_stripN.png with "Import Strip Image"; N is the frame count. Set the origin '
|
|
134
|
+
'to the pivot from the atlas JSON.',
|
|
135
|
+
'rpgmaker': 'RPG Maker: copy $<name>.png into img/characters (3×4 walk sheet).',
|
|
136
|
+
'atlas': 'Generic: <name>.png + <name>.json (TexturePacker hash). meta.clips lists every animation_direction with fps, '
|
|
137
|
+
'loop and frame names; every frame has the same cell size and a normalized pivot at the feet.',
|
|
138
|
+
'strips': 'One horizontal PNG strip per animation_direction; frame width = cell.width.',
|
|
139
|
+
'frames': 'One PNG per frame, in folders per animation_direction.',
|
|
140
|
+
'gif': 'GIF previews only (not for the game).',
|
|
141
|
+
'ability': 'Ability: play the gesture; start the emission effect emission_lead_ms before gesture.release_frame at the '
|
|
142
|
+
'anchor for the current direction (normalized in the gesture cell); at the release frame spawn the '
|
|
143
|
+
'projectile (or stream) at effect_scale × target_height and move it at speed × target_height px/s facing '
|
|
144
|
+
'right (mirror for W); on hit play impact once. Collision and damage are game code. See COMPOSITION.txt.',
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class LinkStore:
|
|
149
|
+
"""Hosted mode: exports are kept in memory for 30 minutes behind an unguessable link."""
|
|
150
|
+
|
|
151
|
+
def __init__(self):
|
|
152
|
+
self._files: dict[str, tuple[str, bytes, float]] = {}
|
|
153
|
+
|
|
154
|
+
def put(self, filename: str, data: bytes) -> str:
|
|
155
|
+
now = time.time()
|
|
156
|
+
self._files = {k: v for k, v in self._files.items() if v[2] > now}
|
|
157
|
+
while sum(len(v[1]) for v in self._files.values()) + len(data) > 256 * 1024 * 1024 and self._files:
|
|
158
|
+
self._files.pop(next(iter(self._files)))
|
|
159
|
+
token = secrets.token_urlsafe(24)
|
|
160
|
+
self._files[token] = (filename, data, now + LINK_TTL)
|
|
161
|
+
return token
|
|
162
|
+
|
|
163
|
+
def get(self, token: str) -> tuple[str, bytes] | None:
|
|
164
|
+
item = self._files.get(token)
|
|
165
|
+
if not item or item[2] < time.time():
|
|
166
|
+
return None
|
|
167
|
+
return item[0], item[1]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def unpack(zip_bytes: bytes, dest: Path) -> list[str]:
|
|
171
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
root = dest.resolve()
|
|
173
|
+
written = []
|
|
174
|
+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive:
|
|
175
|
+
for member in archive.infolist():
|
|
176
|
+
target = (dest / member.filename).resolve()
|
|
177
|
+
if not target.is_relative_to(root): # zip slip
|
|
178
|
+
continue
|
|
179
|
+
if member.is_dir():
|
|
180
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
continue
|
|
182
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
target.write_bytes(archive.read(member))
|
|
184
|
+
written.append(str(target))
|
|
185
|
+
return written
|