spritehatch-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.
@@ -0,0 +1,2 @@
1
+ """SpriteHatch MCP server: lets AI agents generate 2D game assets with SpriteHatch and bring them into the game."""
2
+ __version__ = '0.1.0'
@@ -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()
spritehatch_mcp/api.py ADDED
@@ -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
@@ -0,0 +1,139 @@
1
+ """Spending guardrails for one MCP session (user decision, 2026-10-21: session budget + confirmation above a limit).
2
+
3
+ - `budget`: credits this session may spend in total. Refunded jobs (failures) give the credits back.
4
+ - `confirm_above`: spending more than this WITHOUT the user's approval needs it. The count is cumulative since the last
5
+ approval, so splitting a big request into small ones does not get around it. Approval comes through MCP elicitation
6
+ (the client shows a yes/no to the human) or a one-time `confirm_token` that the agent only sends after asking the user.
7
+ An elicitation that is declined or cancelled is not treated as the user's answer: some clients decline automatically
8
+ without showing anything, so the agent is told to ask in the conversation instead.
9
+ - `auto_improvements`: how many paid "improve" rounds an agent may run on one animation before it must show the result
10
+ to the user. Keeps an agent from looping regenerations on its own.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import secrets
17
+ import time
18
+ from dataclasses import dataclass, field
19
+
20
+ TOKEN_TTL = 15 * 60
21
+ MAX_FAILURES = 2
22
+
23
+
24
+ @dataclass
25
+ class Session:
26
+ budget: int
27
+ confirm_above: int
28
+ auto_improvements: int = 1
29
+ spent: int = 0
30
+ unapproved: int = 0 # credits spent since the user's last approval
31
+ jobs: dict[str, int] = field(default_factory=dict) # job id → credits charged (to return refunds)
32
+ requests: dict[str, str] = field(default_factory=dict) # job id → fingerprint of the request that made it
33
+ failures: dict[str, int] = field(default_factory=dict) # request fingerprint → failed jobs
34
+ settled: set[str] = field(default_factory=set)
35
+ pending: dict[str, tuple[str, int, float]] = field(default_factory=dict) # token → (fingerprint, cost, expires)
36
+ improvements: dict[str, int] = field(default_factory=dict) # "asset/animation" → paid improve rounds
37
+ plans: dict[str, dict] = field(default_factory=dict) # plan id → animation plan from plan_animation
38
+ seen: float = field(default_factory=time.time)
39
+
40
+ @property
41
+ def remaining(self) -> int:
42
+ return max(0, self.budget - self.spent)
43
+
44
+ def view(self) -> dict:
45
+ return {'budget': self.budget, 'spent': self.spent, 'remaining': self.remaining, 'confirm_above': self.confirm_above,
46
+ 'spent_since_last_approval': self.unapproved}
47
+
48
+ def record(self, jobs: list[dict], tool: str = '', args: dict | None = None) -> None:
49
+ request = fingerprint(tool, args or {}) if tool else ''
50
+ for job in jobs:
51
+ if job and job.get('id') and job['id'] not in self.jobs:
52
+ cost = int(job.get('cost') or 0)
53
+ self.jobs[job['id']] = cost
54
+ self.requests[job['id']] = request
55
+ self.spent += cost
56
+
57
+ def settle(self, job: dict) -> None:
58
+ """Count each finished job once: a failure counts against its request, and a refund goes back to the budget."""
59
+ jid = job.get('id')
60
+ if job.get('status') not in ('done', 'error') or jid in self.settled:
61
+ return
62
+ self.settled.add(jid)
63
+ if job['status'] == 'error' and self.requests.get(jid):
64
+ self.failures[self.requests[jid]] = self.failures.get(self.requests[jid], 0) + 1
65
+ if jid in self.jobs and (job.get('refunded') or job.get('partial_refund')):
66
+ self.spent = max(0, self.spent - (self.jobs[jid] if job.get('refunded') else int(job.get('partial_refund') or 0)))
67
+
68
+ def check(self, tool: str, args: dict, cost: int, token: str | None) -> dict | None:
69
+ """None = go ahead. Otherwise the dict explains why not (budget) or asks for approval (confirmation)."""
70
+ if self.failures.get(fingerprint(tool, args), 0) >= MAX_FAILURES:
71
+ return {'status': 'stop_failing', 'message': f'This exact request already failed {MAX_FAILURES} times in this session '
72
+ '(the credits were refunded). Do not retry it: tell the user what failed, or change the request.'}
73
+ if cost <= 0:
74
+ return None
75
+ if cost > self.remaining:
76
+ return {'status': 'budget_exceeded', 'cost': cost, 'session': self.view(),
77
+ 'message': f'This request costs {cost} credits but only {self.remaining} remain in this session budget. '
78
+ 'Do not split it into smaller requests to get around the budget. Tell the user; only they can '
79
+ 'raise the budget (SPRITEHATCH_BUDGET locally, X-SpriteHatch-Budget header when hosted).'}
80
+ if self.unapproved + cost <= self.confirm_above:
81
+ self.unapproved += cost
82
+ return None
83
+ wanted = fingerprint(tool, args)
84
+ now = time.time()
85
+ self.pending = {t: v for t, v in self.pending.items() if v[2] > now}
86
+ if token and token in self.pending and self.pending[token][0] == wanted:
87
+ del self.pending[token]
88
+ self.approved()
89
+ return None
90
+ token = secrets.token_urlsafe(9)
91
+ self.pending[token] = (wanted, cost, now + TOKEN_TTL)
92
+ since = f' ({self.unapproved} already spent since the last approval)' if self.unapproved else ''
93
+ return {'status': 'confirmation_required', 'cost': cost, 'session': self.view(), 'confirm_token': token,
94
+ 'message': f'This request costs {cost} credits{since}, above the {self.confirm_above}-credit limit of spending '
95
+ 'without the user\'s approval. Ask the user in the conversation (say what will be generated and the '
96
+ 'cost). Only if they approve, call the same tool again with the same arguments plus confirm_token '
97
+ '(expires in 15 minutes). If they refuse, do not retry it, and never split it into smaller requests: '
98
+ 'the limit counts everything spent since the last approval.'}
99
+
100
+ def approved(self) -> None:
101
+ """The user approved this request: spending without approval starts counting again from zero."""
102
+ self.unapproved = 0
103
+
104
+ def may_improve(self, asset: str, animation: str) -> bool:
105
+ return self.improvements.get(f'{asset}/{animation}', 0) < self.auto_improvements
106
+
107
+ def improved(self, asset: str, animation: str) -> None:
108
+ key = f'{asset}/{animation}'
109
+ self.improvements[key] = self.improvements.get(key, 0) + 1
110
+
111
+
112
+ def fingerprint(tool: str, args: dict) -> str:
113
+ clean = {k: v for k, v in args.items() if k not in ('confirm_token', 'ctx') and v is not None}
114
+ return hashlib.sha256(f'{tool}:{json.dumps(clean, sort_keys=True, default=str)}'.encode()).hexdigest()
115
+
116
+
117
+ class Sessions:
118
+ """Local mode has one session (the process). Hosted mode keys sessions by MCP session id (or API key) and forgets
119
+ idle ones after a day."""
120
+
121
+ def __init__(self, budget: int, confirm_above: int, auto_improvements: int = 1):
122
+ self.defaults = (budget, confirm_above, auto_improvements)
123
+ self._all: dict[str, Session] = {}
124
+
125
+ def get(self, key: str, budget: int | None = None, confirm_above: int | None = None) -> Session:
126
+ now = time.time()
127
+ if len(self._all) > 500:
128
+ self._all = {k: s for k, s in self._all.items() if now - s.seen < 86400}
129
+ session = self._all.get(key)
130
+ if session is None:
131
+ b, c, a = self.defaults
132
+ session = self._all[key] = Session(b, c, a)
133
+ # hosted clients send their limits on every request; the latest ones win (connections may share a session)
134
+ if budget is not None:
135
+ session.budget = budget
136
+ if confirm_above is not None:
137
+ session.confirm_above = confirm_above
138
+ session.seen = now
139
+ return session