thinhost-mcp 1.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.
thinhost_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.1.0"
|
thinhost_mcp/server.py
ADDED
|
@@ -0,0 +1,997 @@
|
|
|
1
|
+
"""thin.host MCP Server — hosting, domain provisioning + transactional email tools for AI agents.
|
|
2
|
+
|
|
3
|
+
Exposes 11 tools to MCP-compatible agents (Claude, GPT, etc.):
|
|
4
|
+
- publish_website: Publish a local HTML file, directory, ZIP, or static build folder
|
|
5
|
+
(Docusaurus, Astro, Vite, Next export) as a live hosted site — batched for big builds
|
|
6
|
+
- update_website: Push updated pages/assets to an existing hosted site; sync=True prunes
|
|
7
|
+
files the new build no longer ships (hashed chunks from earlier builds)
|
|
8
|
+
- publish_website_from_url / update_website_from_url: same, from a ZIP or HTML URL — the
|
|
9
|
+
only publish path on the hosted endpoint (https://thin.host/mcp), which cannot read your disk
|
|
10
|
+
- list_websites: List the account's hosted sites with their live URLs
|
|
11
|
+
- provision_domain: Create a new custom domain with payment/claim flow
|
|
12
|
+
- check_domain_status: Poll domain provisioning progress
|
|
13
|
+
- update_origin: Change the target URL for an existing domain
|
|
14
|
+
- release_domain: Tear down a provisioned domain
|
|
15
|
+
- list_domains: List all domains with optional status filtering
|
|
16
|
+
- send_email: Send a transactional notification email (verified-domain/allowlist gated)
|
|
17
|
+
|
|
18
|
+
Run: python -m app.mcp_server (or `thinhost-mcp` from the wheel)
|
|
19
|
+
Hosted: POST https://thin.host/mcp with Authorization: Bearer <API key or OAuth token>
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import contextvars
|
|
24
|
+
import ipaddress
|
|
25
|
+
import os
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import posixpath
|
|
29
|
+
import re
|
|
30
|
+
import socket
|
|
31
|
+
import tempfile
|
|
32
|
+
import zipfile
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from urllib.parse import urlparse
|
|
35
|
+
|
|
36
|
+
import httpx
|
|
37
|
+
from mcp.server.fastmcp import FastMCP
|
|
38
|
+
|
|
39
|
+
logging.basicConfig(level=logging.INFO)
|
|
40
|
+
logger = logging.getLogger("thinhost-mcp")
|
|
41
|
+
|
|
42
|
+
MCP_VERSION = "1.1.0"
|
|
43
|
+
|
|
44
|
+
# Configuration from environment
|
|
45
|
+
API_BASE_URL = os.environ.get("THINHOST_API_URL", "http://localhost:8885/v1")
|
|
46
|
+
API_KEY = os.environ.get("THIN_HOST_API_KEY", "")
|
|
47
|
+
|
|
48
|
+
# Hosted mode (app/routes/mcp_http.py) runs these same tool functions inside
|
|
49
|
+
# the web app: it installs a client factory that dispatches straight into the
|
|
50
|
+
# Flask app (no network hop) and sets the caller's credential per request via
|
|
51
|
+
# the context var, so the tools never see a module-level key there.
|
|
52
|
+
CLIENT_FACTORY = None
|
|
53
|
+
REQUEST_AUTH: contextvars.ContextVar = contextvars.ContextVar("thinhost_mcp_auth", default=None)
|
|
54
|
+
|
|
55
|
+
# Tools that read the local filesystem make no sense on the hosted endpoint,
|
|
56
|
+
# where "local" would be the server. Everything else is safe to expose.
|
|
57
|
+
LOCAL_ONLY_TOOLS = {"publish_website", "update_website"}
|
|
58
|
+
|
|
59
|
+
mcp = FastMCP(
|
|
60
|
+
"thin.host",
|
|
61
|
+
instructions=(
|
|
62
|
+
"thin.host hosts AI-generated sites and apps, provisions custom domains for them, "
|
|
63
|
+
"and sends transactional email. After generating a site (HTML file, directory, or "
|
|
64
|
+
"ZIP, or a static build folder such as a Docusaurus `build/` directory), use "
|
|
65
|
+
"publish_website to put it live and give the user the returned url instead of "
|
|
66
|
+
"telling them to upload the files somewhere themselves; push later changes with "
|
|
67
|
+
"update_website — pass sync=true when redeploying a build folder so files the new "
|
|
68
|
+
"build dropped are removed. When the site is reachable as a ZIP or HTML URL rather "
|
|
69
|
+
"than a local path (always the case on the hosted endpoint), use "
|
|
70
|
+
"publish_website_from_url / update_website_from_url instead. Use provision_domain to set up a custom domain, then direct the "
|
|
71
|
+
"user to the claim_url to complete payment and DNS configuration. Use "
|
|
72
|
+
"check_domain_status to poll until ready. Use send_email to send transactional "
|
|
73
|
+
"notification emails — this requires the account to be allowlisted for email and the "
|
|
74
|
+
"'from' address to use a verified sending domain."
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _headers():
|
|
80
|
+
override = REQUEST_AUTH.get()
|
|
81
|
+
if override:
|
|
82
|
+
return {**override, "Content-Type": "application/json"}
|
|
83
|
+
return {
|
|
84
|
+
"X-Platform-API-Key": API_KEY,
|
|
85
|
+
"Content-Type": "application/json",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _client(**kwargs):
|
|
90
|
+
"""The HTTP client the tools talk to the API with (hosted mode overrides it)."""
|
|
91
|
+
if CLIENT_FACTORY is not None:
|
|
92
|
+
return CLIENT_FACTORY(**kwargs)
|
|
93
|
+
return httpx.AsyncClient(**kwargs)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _format_error(resp_data):
|
|
97
|
+
"""Format an API error into an agent-friendly structure."""
|
|
98
|
+
err = resp_data.get("error", {})
|
|
99
|
+
return {
|
|
100
|
+
"error_code": err.get("code", "unknown"),
|
|
101
|
+
"error_message": err.get("message", "Unknown error"),
|
|
102
|
+
"agent_should_retry": err.get("retryable", False),
|
|
103
|
+
"suggested_next_tool_call": err.get("next_action", ""),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@mcp.tool()
|
|
108
|
+
async def provision_domain(
|
|
109
|
+
domain: str,
|
|
110
|
+
origin_url: str,
|
|
111
|
+
user_email: str,
|
|
112
|
+
idempotency_key: str | None = None,
|
|
113
|
+
) -> dict:
|
|
114
|
+
"""Provision a new custom domain for an AI-generated site or app.
|
|
115
|
+
|
|
116
|
+
Call this when a user wants their deployed site accessible at a custom domain.
|
|
117
|
+
This creates the domain mapping and returns a claim_url where the user completes
|
|
118
|
+
payment ($8/mo) and DNS setup. The domain is NOT immediately active — the user
|
|
119
|
+
must visit the claim_url first.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
domain: The custom domain to provision (e.g., "mysite.com", "portfolio.dev")
|
|
123
|
+
origin_url: The current URL where the site is hosted (e.g., "https://myapp.vercel.app")
|
|
124
|
+
user_email: The end user's email address for billing and notifications
|
|
125
|
+
idempotency_key: Optional key to prevent duplicate provisions on retry
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
provision_id: Unique ID to track this domain (use with check_domain_status)
|
|
129
|
+
status: Current provisioning status enum (pending_payment, pending_dns, pending_ssl, active, failed, released)
|
|
130
|
+
dns_instructions: Structured DNS record the user needs to create, plus a human_readable summary
|
|
131
|
+
claim_url: URL to send the user to for payment and DNS setup walkthrough
|
|
132
|
+
expected_completion_seconds: Estimated time for full provisioning after payment
|
|
133
|
+
"""
|
|
134
|
+
async with _client() as client:
|
|
135
|
+
payload = {
|
|
136
|
+
"domain": domain,
|
|
137
|
+
"origin_url": origin_url,
|
|
138
|
+
"end_user_email": user_email,
|
|
139
|
+
}
|
|
140
|
+
if idempotency_key:
|
|
141
|
+
payload["idempotency_key"] = idempotency_key
|
|
142
|
+
|
|
143
|
+
resp = await client.post(
|
|
144
|
+
f"{API_BASE_URL}/domains",
|
|
145
|
+
headers=_headers(),
|
|
146
|
+
json=payload,
|
|
147
|
+
timeout=30,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
data = resp.json()
|
|
151
|
+
if resp.status_code >= 400:
|
|
152
|
+
return _format_error(data)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"provision_id": data["id"],
|
|
156
|
+
"status": data["status"],
|
|
157
|
+
"dns_instructions": data["dns_instructions"],
|
|
158
|
+
"claim_url": data["claim_url"],
|
|
159
|
+
"expected_completion_seconds": data.get("expected_completion_seconds", 300),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@mcp.tool()
|
|
164
|
+
async def check_domain_status(provision_id: str) -> dict:
|
|
165
|
+
"""Check the current provisioning status of a domain.
|
|
166
|
+
|
|
167
|
+
Call this to poll whether a domain is ready. Typical flow:
|
|
168
|
+
1. provision_domain → returns pending_payment
|
|
169
|
+
2. User visits claim_url and pays → status becomes pending_dns
|
|
170
|
+
3. User configures DNS → status becomes pending_ssl
|
|
171
|
+
4. SSL auto-provisions → status becomes active, ready=true
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
provision_id: The provision_id returned by provision_domain
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
status: Current status enum (pending_payment, pending_dns, pending_ssl, active, failed, released)
|
|
178
|
+
dns_status: Whether DNS is resolving (resolved, unresolved)
|
|
179
|
+
ssl_status: SSL certificate state (pending, active, failed)
|
|
180
|
+
payment_status: Whether the user has paid (paid, unpaid, unknown)
|
|
181
|
+
ready: Boolean — true only when domain is fully active with SSL
|
|
182
|
+
failure_reason: Present only if status is "failed", explains what went wrong
|
|
183
|
+
"""
|
|
184
|
+
async with _client() as client:
|
|
185
|
+
resp = await client.get(
|
|
186
|
+
f"{API_BASE_URL}/domains/{provision_id}",
|
|
187
|
+
headers=_headers(),
|
|
188
|
+
timeout=15,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
data = resp.json()
|
|
192
|
+
if resp.status_code >= 400:
|
|
193
|
+
return _format_error(data)
|
|
194
|
+
|
|
195
|
+
result = {
|
|
196
|
+
"status": data["status"],
|
|
197
|
+
"dns_status": data["dns_status"],
|
|
198
|
+
"ssl_status": data["ssl_status"],
|
|
199
|
+
"payment_status": data["payment_status"],
|
|
200
|
+
"ready": data["ready"],
|
|
201
|
+
}
|
|
202
|
+
if data["status"] == "failed":
|
|
203
|
+
result["failure_reason"] = data.get("failure_reason", "Unknown failure")
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@mcp.tool()
|
|
208
|
+
async def update_origin(provision_id: str, new_origin_url: str) -> dict:
|
|
209
|
+
"""Change the origin/target URL for an existing domain.
|
|
210
|
+
|
|
211
|
+
Use this when the user redeploys their site to a new URL and wants the
|
|
212
|
+
custom domain to point to the new location.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
provision_id: The provision_id of the domain to update
|
|
216
|
+
new_origin_url: The new URL to route traffic to (e.g., "https://new-deploy.vercel.app")
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
status: Current domain status
|
|
220
|
+
updated_at: Timestamp of the update
|
|
221
|
+
"""
|
|
222
|
+
async with _client() as client:
|
|
223
|
+
resp = await client.patch(
|
|
224
|
+
f"{API_BASE_URL}/domains/{provision_id}",
|
|
225
|
+
headers=_headers(),
|
|
226
|
+
json={"origin_url": new_origin_url},
|
|
227
|
+
timeout=30,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
data = resp.json()
|
|
231
|
+
if resp.status_code >= 400:
|
|
232
|
+
return _format_error(data)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
"status": data["status"],
|
|
236
|
+
"updated_at": data["updated_at"],
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@mcp.tool()
|
|
241
|
+
async def release_domain(provision_id: str) -> dict:
|
|
242
|
+
"""Release a provisioned domain, removing it from thin.host.
|
|
243
|
+
|
|
244
|
+
This tears down the domain mapping, SSL certificate, and cancels billing.
|
|
245
|
+
The domain becomes available for re-provisioning. This action is not reversible.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
provision_id: The provision_id of the domain to release
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
status: "released"
|
|
252
|
+
released_at: Timestamp of the release
|
|
253
|
+
"""
|
|
254
|
+
async with _client() as client:
|
|
255
|
+
resp = await client.delete(
|
|
256
|
+
f"{API_BASE_URL}/domains/{provision_id}",
|
|
257
|
+
headers=_headers(),
|
|
258
|
+
timeout=30,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
data = resp.json()
|
|
262
|
+
if resp.status_code >= 400:
|
|
263
|
+
return _format_error(data)
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
"status": data["status"],
|
|
267
|
+
"released_at": data["released_at"],
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
@mcp.tool()
|
|
272
|
+
async def list_domains(status_filter: str | None = None, limit: int = 50) -> dict:
|
|
273
|
+
"""List all domains provisioned through your platform account.
|
|
274
|
+
|
|
275
|
+
Use this to build dashboards or check the state of all domains.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
status_filter: Optional filter by status (pending_payment, pending_dns, pending_ssl, active, failed, released)
|
|
279
|
+
limit: Maximum number of domains to return (default 50, max 100)
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
domains: Array of domain objects with id, hostname, status, ready fields
|
|
283
|
+
total: Total number of matching domains
|
|
284
|
+
has_more: Whether more results exist beyond this page
|
|
285
|
+
"""
|
|
286
|
+
params = {"limit": min(limit, 100)}
|
|
287
|
+
if status_filter:
|
|
288
|
+
params["status"] = status_filter
|
|
289
|
+
|
|
290
|
+
async with _client() as client:
|
|
291
|
+
resp = await client.get(
|
|
292
|
+
f"{API_BASE_URL}/domains",
|
|
293
|
+
headers=_headers(),
|
|
294
|
+
params=params,
|
|
295
|
+
timeout=15,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
data = resp.json()
|
|
299
|
+
if resp.status_code >= 400:
|
|
300
|
+
return _format_error(data)
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
"domains": data["domains"],
|
|
304
|
+
"total": data["total"],
|
|
305
|
+
"has_more": data["has_more"],
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@mcp.tool()
|
|
310
|
+
async def send_email(
|
|
311
|
+
to: str,
|
|
312
|
+
subject: str,
|
|
313
|
+
text: str | None = None,
|
|
314
|
+
html: str | None = None,
|
|
315
|
+
from_address: str | None = None,
|
|
316
|
+
reply_to: str | None = None,
|
|
317
|
+
) -> dict:
|
|
318
|
+
"""Send a single transactional notification email (e.g. a sign-in link, receipt, or alert).
|
|
319
|
+
|
|
320
|
+
PRECONDITION — this only works for accounts thin.host has approved for email AND
|
|
321
|
+
when the 'from' address uses a verified sending domain. There is no self-serve:
|
|
322
|
+
- Unapproved accounts get error_code "email_not_enabled".
|
|
323
|
+
- A 'from' address on an unverified/unapproved domain gets "invalid_from_domain".
|
|
324
|
+
In both cases the returned suggested_next_tool_call explains how to get approved or
|
|
325
|
+
which sending domain to use. Do NOT retry blindly — surface that guidance to the user.
|
|
326
|
+
|
|
327
|
+
Omit 'from_address' to send from the account's default verified sender (recommended).
|
|
328
|
+
Provide at least one of 'text' or 'html'. This is transactional only — do not use it
|
|
329
|
+
for marketing or bulk sends.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
to: Recipient email address
|
|
333
|
+
subject: Email subject line
|
|
334
|
+
text: Plain-text body (provide this and/or html)
|
|
335
|
+
html: HTML body (provide this and/or text)
|
|
336
|
+
from_address: Optional sender; must be on a verified sending domain. Omit for the default.
|
|
337
|
+
reply_to: Optional Reply-To address
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
id: The provider message ID for the accepted send
|
|
341
|
+
status: "sent" when the message was accepted for delivery
|
|
342
|
+
to: The recipient the message was sent to
|
|
343
|
+
from_address: The sender the message was sent from
|
|
344
|
+
|
|
345
|
+
On failure returns the standard agent error shape (error_code, error_message,
|
|
346
|
+
agent_should_retry, suggested_next_tool_call). Common error_codes: email_not_enabled,
|
|
347
|
+
invalid_from_domain, recipient_suppressed (prior bounce/complaint — do not retry),
|
|
348
|
+
rate_limited (retryable after the window resets), invalid_to, missing_subject, empty_body.
|
|
349
|
+
"""
|
|
350
|
+
payload = {"to": to, "subject": subject}
|
|
351
|
+
if text is not None:
|
|
352
|
+
payload["text"] = text
|
|
353
|
+
if html is not None:
|
|
354
|
+
payload["html"] = html
|
|
355
|
+
if from_address:
|
|
356
|
+
payload["from"] = from_address
|
|
357
|
+
if reply_to:
|
|
358
|
+
payload["reply_to"] = reply_to
|
|
359
|
+
|
|
360
|
+
async with _client() as client:
|
|
361
|
+
resp = await client.post(
|
|
362
|
+
f"{API_BASE_URL}/emails",
|
|
363
|
+
headers=_headers(),
|
|
364
|
+
json=payload,
|
|
365
|
+
timeout=30,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
data = resp.json()
|
|
369
|
+
if resp.status_code >= 400:
|
|
370
|
+
return _format_error(data)
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"id": data["id"],
|
|
374
|
+
"status": data["status"],
|
|
375
|
+
"to": data["to"],
|
|
376
|
+
"from_address": data["from"],
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# ---------------------------------------------------------------------------
|
|
381
|
+
# Website publishing (hosted sites)
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
# Mirrors the API's asset allowlist; html/htm become pages.
|
|
385
|
+
_ASSET_EXTENSIONS = {"css", "js", "png", "jpg", "jpeg", "gif", "svg", "ico", "webp",
|
|
386
|
+
"woff", "woff2", "ttf", "json", "xml", "txt", "pdf",
|
|
387
|
+
"mjs", "map", "webmanifest", "avif", "mp4", "webm", "mp3",
|
|
388
|
+
"otf", "eot", "wasm", "csv", "md", "yaml", "yml"}
|
|
389
|
+
# A deploy may span many requests; this caps the whole site so a stray
|
|
390
|
+
# node_modules folder or video library fails fast with a clear message.
|
|
391
|
+
_MAX_SITE_BYTES = 150 * 1024 * 1024
|
|
392
|
+
# Per-request budget, measured on the encoded payload so each request stays
|
|
393
|
+
# under the API's 20MB limit with headroom. Page/asset caps mirror the API.
|
|
394
|
+
_BATCH_BYTES = 15 * 1024 * 1024
|
|
395
|
+
_BATCH_PAGES = 200
|
|
396
|
+
_BATCH_ASSETS = 400
|
|
397
|
+
_REQUEST_TIMEOUT = 300
|
|
398
|
+
# Full path lists are useful up to a point; a 700-file build would flood the
|
|
399
|
+
# agent's context, so above this the response carries counts plus a sample.
|
|
400
|
+
_MAX_LISTED_PATHS = 50
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _is_junk(rel_path: str) -> bool:
|
|
404
|
+
parts = rel_path.split("/")
|
|
405
|
+
return any(p == "__MACOSX" or p == ".DS_Store" or p.startswith("._") or p.startswith(".git")
|
|
406
|
+
for p in parts)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _page_path_for(rel_path: str) -> str:
|
|
410
|
+
"""index.html -> /, about.html -> /about, docs/index.html -> /docs, 404.html -> /404."""
|
|
411
|
+
parts = rel_path.split("/")
|
|
412
|
+
base = parts[-1].rsplit(".", 1)[0]
|
|
413
|
+
dirs = parts[:-1]
|
|
414
|
+
if base.lower() == "index":
|
|
415
|
+
return "/" + "/".join(dirs) if dirs else "/"
|
|
416
|
+
return "/" + "/".join(dirs + [base])
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _collect_site_files(source_path: str):
|
|
420
|
+
"""Read a local HTML file, directory, or ZIP into (pages, assets, skipped).
|
|
421
|
+
|
|
422
|
+
pages = [{"path": url_path, "html": str}]
|
|
423
|
+
assets = [{"path": "/rel/path", "content_base64": str}]
|
|
424
|
+
Raises ValueError with an agent-readable message on bad input.
|
|
425
|
+
"""
|
|
426
|
+
src = Path(source_path).expanduser()
|
|
427
|
+
if not src.exists():
|
|
428
|
+
raise ValueError(f"Path not found: {source_path}")
|
|
429
|
+
|
|
430
|
+
entries = [] # (rel_path, bytes)
|
|
431
|
+
if src.is_dir():
|
|
432
|
+
for f in sorted(src.rglob("*")):
|
|
433
|
+
if f.is_file():
|
|
434
|
+
rel = f.relative_to(src).as_posix()
|
|
435
|
+
entries.append((rel, f.read_bytes()))
|
|
436
|
+
elif src.suffix.lower() == ".zip":
|
|
437
|
+
with zipfile.ZipFile(src) as zf:
|
|
438
|
+
for info in zf.infolist():
|
|
439
|
+
if not info.is_dir():
|
|
440
|
+
rel = posixpath.normpath(info.filename.replace("\\", "/"))
|
|
441
|
+
if not posixpath.isabs(rel) and not rel.startswith(".."):
|
|
442
|
+
entries.append((rel, zf.read(info.filename)))
|
|
443
|
+
elif src.suffix.lower() in (".html", ".htm"):
|
|
444
|
+
entries.append(("index.html", src.read_bytes()))
|
|
445
|
+
else:
|
|
446
|
+
raise ValueError(
|
|
447
|
+
f"Unsupported source: {source_path}. Pass an .html file, a directory, or a .zip."
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
pages, assets, skipped, total = [], [], [], 0
|
|
451
|
+
for rel, content in entries:
|
|
452
|
+
if _is_junk(rel):
|
|
453
|
+
continue
|
|
454
|
+
ext = rel.rsplit(".", 1)[-1].lower() if "." in rel else ""
|
|
455
|
+
if ext in ("html", "htm"):
|
|
456
|
+
pages.append({"path": _page_path_for(rel),
|
|
457
|
+
"html": content.decode("utf-8", errors="replace")})
|
|
458
|
+
elif ext in _ASSET_EXTENSIONS:
|
|
459
|
+
assets.append({"path": "/" + rel,
|
|
460
|
+
"content_base64": base64.b64encode(content).decode("ascii")})
|
|
461
|
+
else:
|
|
462
|
+
skipped.append(rel)
|
|
463
|
+
continue
|
|
464
|
+
total += len(content)
|
|
465
|
+
if total > _MAX_SITE_BYTES:
|
|
466
|
+
raise ValueError(
|
|
467
|
+
f"Site exceeds {_MAX_SITE_BYTES // (1024 * 1024)}MB. Remove large files "
|
|
468
|
+
"(videos, huge images) or split the upload."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
if not pages:
|
|
472
|
+
raise ValueError("No HTML pages found — a site needs at least one .html file.")
|
|
473
|
+
return pages, assets, skipped
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
# Static-site generators stamp themselves into <meta name="generator">; the
|
|
477
|
+
# minified Docusaurus output leaves attribute values unquoted, so both forms
|
|
478
|
+
# are matched. The base URL is read off the runtime chunk / stylesheet link,
|
|
479
|
+
# which Docusaurus always prefixes with the configured baseUrl.
|
|
480
|
+
_GENERATOR_RE = re.compile(
|
|
481
|
+
r'<meta\s[^>]*?name=["\']?generator["\']?\s[^>]*?content=(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))',
|
|
482
|
+
re.I,
|
|
483
|
+
)
|
|
484
|
+
_DOCUSAURUS_BASE_RE = re.compile(
|
|
485
|
+
r'(?:src|href)=["\']?([^"\'\s>]*?)assets/(?:js/runtime~main|css/styles)\.', re.I
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _detect_framework(pages):
|
|
490
|
+
"""Sniff (framework, base_url) from the root page. Both None when unknown."""
|
|
491
|
+
root = next((p for p in pages if p["path"] == "/"), None) or (pages[0] if pages else None)
|
|
492
|
+
if root is None:
|
|
493
|
+
return None, None
|
|
494
|
+
html = root["html"][:20000]
|
|
495
|
+
m = _GENERATOR_RE.search(html)
|
|
496
|
+
generator = (next((g for g in m.groups() if g), "") if m else "").lower()
|
|
497
|
+
framework = None
|
|
498
|
+
for name in ("docusaurus", "astro", "hugo", "gatsby", "eleventy", "vitepress", "mkdocs"):
|
|
499
|
+
if name in generator:
|
|
500
|
+
framework = name
|
|
501
|
+
break
|
|
502
|
+
base_url = None
|
|
503
|
+
if framework == "docusaurus":
|
|
504
|
+
b = _DOCUSAURUS_BASE_RE.search(html)
|
|
505
|
+
if b:
|
|
506
|
+
base_url = b.group(1) or "/"
|
|
507
|
+
if "://" in base_url:
|
|
508
|
+
base_url = urlparse(base_url).path or "/"
|
|
509
|
+
if not base_url.endswith("/"):
|
|
510
|
+
base_url += "/"
|
|
511
|
+
return framework, base_url
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _base_url_guidance(framework, base_url, slug):
|
|
515
|
+
"""A warning when a Docusaurus build's baseUrl won't resolve where it is served.
|
|
516
|
+
|
|
517
|
+
Docusaurus writes root-absolute asset URLs (``/assets/js/...``) built from
|
|
518
|
+
``baseUrl``. Those resolve on a custom domain mapped to the site, but on the
|
|
519
|
+
shared preview origin the site lives under ``/s/<slug>/`` and a ``/``-based
|
|
520
|
+
build renders unstyled there. The agent surfaces this rather than the user
|
|
521
|
+
discovering a blank preview.
|
|
522
|
+
"""
|
|
523
|
+
if framework != "docusaurus" or not base_url:
|
|
524
|
+
return None
|
|
525
|
+
expected = f"/s/{slug}/"
|
|
526
|
+
if base_url == expected:
|
|
527
|
+
return None
|
|
528
|
+
if base_url == "/":
|
|
529
|
+
return (
|
|
530
|
+
"This Docusaurus build uses baseUrl '/', so its scripts and styles load from the "
|
|
531
|
+
"domain root. That works once a custom domain is mapped to this site (dashboard → "
|
|
532
|
+
f"Website → Domains); the preview URL /s/{slug}/ will render unstyled until then. "
|
|
533
|
+
f"To preview on thin.host, rebuild with baseUrl '{expected}' and call "
|
|
534
|
+
"update_website with sync=true."
|
|
535
|
+
)
|
|
536
|
+
return (
|
|
537
|
+
f"This Docusaurus build uses baseUrl '{base_url}', which matches neither this site's "
|
|
538
|
+
f"preview path '{expected}' nor a custom-domain root '/'. Rebuild with the right "
|
|
539
|
+
"baseUrl and call update_website with sync=true."
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _batches(pages, assets):
|
|
544
|
+
"""Split a deploy into request-sized batches — assets first, then pages.
|
|
545
|
+
|
|
546
|
+
Assets go first so a page is never live while a chunk it references is
|
|
547
|
+
still in flight; hashed filenames make new chunks harmless to old pages.
|
|
548
|
+
Budgets are measured on the encoded payload the API will receive.
|
|
549
|
+
"""
|
|
550
|
+
batches = []
|
|
551
|
+
cur = {"pages": [], "assets": []}
|
|
552
|
+
cur_bytes = 0
|
|
553
|
+
|
|
554
|
+
def flush():
|
|
555
|
+
nonlocal cur, cur_bytes
|
|
556
|
+
if cur["pages"] or cur["assets"]:
|
|
557
|
+
batches.append(cur)
|
|
558
|
+
cur = {"pages": [], "assets": []}
|
|
559
|
+
cur_bytes = 0
|
|
560
|
+
|
|
561
|
+
for a in assets:
|
|
562
|
+
size = len(a["content_base64"]) + len(a["path"]) + 64
|
|
563
|
+
if cur["assets"] and (cur_bytes + size > _BATCH_BYTES or len(cur["assets"]) >= _BATCH_ASSETS):
|
|
564
|
+
flush()
|
|
565
|
+
cur["assets"].append(a)
|
|
566
|
+
cur_bytes += size
|
|
567
|
+
for pg in pages:
|
|
568
|
+
size = len(pg["html"].encode("utf-8")) + len(pg["path"]) + 64
|
|
569
|
+
if (cur["pages"] or cur["assets"]) and (
|
|
570
|
+
cur_bytes + size > _BATCH_BYTES or len(cur["pages"]) >= _BATCH_PAGES
|
|
571
|
+
):
|
|
572
|
+
flush()
|
|
573
|
+
cur["pages"].append(pg)
|
|
574
|
+
cur_bytes += size
|
|
575
|
+
flush()
|
|
576
|
+
return batches
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _website_ref(website: str) -> str:
|
|
580
|
+
"""Accept a website id, slug, or live URL and return the id-or-slug ref."""
|
|
581
|
+
ref = website.strip().rstrip("/")
|
|
582
|
+
if "://" in ref:
|
|
583
|
+
# https://thin.host/s/<slug>[/...] -> slug
|
|
584
|
+
parts = ref.split("/")
|
|
585
|
+
if "s" in parts:
|
|
586
|
+
return parts[parts.index("s") + 1]
|
|
587
|
+
return parts[-1]
|
|
588
|
+
return ref
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
async def _push(client, method, url, payload):
|
|
592
|
+
"""One API request → (status, json). Non-JSON bodies become a structured error."""
|
|
593
|
+
resp = await client.request(method, url, headers=_headers(), json=payload,
|
|
594
|
+
timeout=_REQUEST_TIMEOUT)
|
|
595
|
+
try:
|
|
596
|
+
data = resp.json()
|
|
597
|
+
except ValueError:
|
|
598
|
+
data = {"error": {
|
|
599
|
+
"code": f"http_{resp.status_code}",
|
|
600
|
+
"message": resp.text[:300],
|
|
601
|
+
"next_action": "Retry the deploy; if it keeps failing, report the status code to thin.host.",
|
|
602
|
+
"retryable": resp.status_code >= 500,
|
|
603
|
+
}}
|
|
604
|
+
return resp.status_code, data
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
async def _deploy_batches(client, ref, batches, offset=0, total=None):
|
|
608
|
+
"""PATCH each batch onto the site. Returns an agent error dict, or None."""
|
|
609
|
+
total = total or (len(batches) + offset)
|
|
610
|
+
for i, batch in enumerate(batches):
|
|
611
|
+
status, data = await _push(client, "PATCH", f"{API_BASE_URL}/websites/{ref}", batch)
|
|
612
|
+
if status >= 400:
|
|
613
|
+
err = _format_error(data)
|
|
614
|
+
err["error_message"] = (
|
|
615
|
+
f"Deploy stopped at request {offset + i + 1} of {total}: {err['error_message']} "
|
|
616
|
+
"The site may be partially updated — fix the cause, then run update_website "
|
|
617
|
+
"again (with sync=true for a build folder)."
|
|
618
|
+
)
|
|
619
|
+
err["requests_completed"] = offset + i
|
|
620
|
+
err["requests_total"] = total
|
|
621
|
+
return err
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _listed(paths):
|
|
626
|
+
paths = sorted(paths)
|
|
627
|
+
if len(paths) <= _MAX_LISTED_PATHS:
|
|
628
|
+
return paths
|
|
629
|
+
return paths[:_MAX_LISTED_PATHS] + [f"… {len(paths) - _MAX_LISTED_PATHS} more"]
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _invalid_source(e, tool):
|
|
633
|
+
return {
|
|
634
|
+
"error_code": "invalid_source",
|
|
635
|
+
"error_message": str(e),
|
|
636
|
+
"agent_should_retry": False,
|
|
637
|
+
"suggested_next_tool_call": f"Fix the source files and call {tool} again",
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
async def _do_publish(pages, assets, skipped, title, slug):
|
|
642
|
+
framework, base_url = _detect_framework(pages)
|
|
643
|
+
root = next((p for p in pages if p["path"] == "/"), pages[0])
|
|
644
|
+
payload = {"title": title, "pages": [root], "assets": []}
|
|
645
|
+
if slug:
|
|
646
|
+
payload["slug"] = slug
|
|
647
|
+
|
|
648
|
+
rest = _batches(pages, assets)
|
|
649
|
+
async with _client() as client:
|
|
650
|
+
status, data = await _push(client, "POST", f"{API_BASE_URL}/websites", payload)
|
|
651
|
+
if status >= 400:
|
|
652
|
+
return _format_error(data)
|
|
653
|
+
err = await _deploy_batches(client, data["slug"], rest, offset=1, total=len(rest) + 1)
|
|
654
|
+
if err:
|
|
655
|
+
err.update({"url": data["url"], "id": data["id"], "slug": data["slug"]})
|
|
656
|
+
return err
|
|
657
|
+
|
|
658
|
+
result = {
|
|
659
|
+
"url": data["url"],
|
|
660
|
+
"id": data["id"],
|
|
661
|
+
"slug": data["slug"],
|
|
662
|
+
"title": data["title"],
|
|
663
|
+
"page_count": len(pages),
|
|
664
|
+
"asset_count": len(assets),
|
|
665
|
+
"pages": _listed(p["path"] for p in pages),
|
|
666
|
+
"assets": _listed(a["path"] for a in assets),
|
|
667
|
+
"requests": len(rest) + 1,
|
|
668
|
+
"skipped_files": skipped,
|
|
669
|
+
"next_action": data.get("next_action", ""),
|
|
670
|
+
}
|
|
671
|
+
if framework:
|
|
672
|
+
result["framework"] = framework
|
|
673
|
+
result["base_url"] = base_url
|
|
674
|
+
warning = _base_url_guidance(framework, base_url, data["slug"])
|
|
675
|
+
if warning:
|
|
676
|
+
result["warning"] = warning
|
|
677
|
+
result["next_action"] = warning
|
|
678
|
+
return result
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
async def _do_update(website, pages, assets, skipped, title, sync):
|
|
682
|
+
framework, base_url = _detect_framework(pages)
|
|
683
|
+
ref = _website_ref(website)
|
|
684
|
+
batches = _batches(pages, assets)
|
|
685
|
+
if title:
|
|
686
|
+
batches[0]["title"] = title
|
|
687
|
+
total = len(batches) + (1 if sync else 0)
|
|
688
|
+
|
|
689
|
+
async with _client() as client:
|
|
690
|
+
err = await _deploy_batches(client, ref, batches, total=total)
|
|
691
|
+
if err:
|
|
692
|
+
return err
|
|
693
|
+
if sync:
|
|
694
|
+
manifest = {"keep": {"pages": [p["path"] for p in pages],
|
|
695
|
+
"assets": [a["path"] for a in assets]}}
|
|
696
|
+
status, data = await _push(client, "POST", f"{API_BASE_URL}/websites/{ref}/sync", manifest)
|
|
697
|
+
else:
|
|
698
|
+
status, data = await _push(client, "GET", f"{API_BASE_URL}/websites/{ref}", None)
|
|
699
|
+
if status >= 400:
|
|
700
|
+
return _format_error(data)
|
|
701
|
+
|
|
702
|
+
result = {
|
|
703
|
+
"url": data["url"],
|
|
704
|
+
"id": data["id"],
|
|
705
|
+
"slug": data["slug"],
|
|
706
|
+
"title": data["title"],
|
|
707
|
+
"updated_page_count": len(pages),
|
|
708
|
+
"updated_asset_count": len(assets),
|
|
709
|
+
"updated_pages": _listed(p["path"] for p in pages),
|
|
710
|
+
"updated_assets": _listed(a["path"] for a in assets),
|
|
711
|
+
"requests": total,
|
|
712
|
+
"skipped_files": skipped,
|
|
713
|
+
}
|
|
714
|
+
if sync:
|
|
715
|
+
result["removed_pages"] = data.get("removed_pages", [])
|
|
716
|
+
result["removed_assets"] = _listed(data.get("removed_assets", []))
|
|
717
|
+
result["removed_asset_count"] = len(data.get("removed_assets", []))
|
|
718
|
+
result["kept_redirects"] = data.get("kept_redirects", [])
|
|
719
|
+
if framework:
|
|
720
|
+
result["framework"] = framework
|
|
721
|
+
result["base_url"] = base_url
|
|
722
|
+
warning = _base_url_guidance(framework, base_url, data["slug"])
|
|
723
|
+
if warning:
|
|
724
|
+
result["warning"] = warning
|
|
725
|
+
return result
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@mcp.tool()
|
|
729
|
+
async def publish_website(
|
|
730
|
+
source_path: str,
|
|
731
|
+
title: str,
|
|
732
|
+
slug: str | None = None,
|
|
733
|
+
) -> dict:
|
|
734
|
+
"""Publish a local HTML file, directory, ZIP, or static build folder as a live site on thin.host.
|
|
735
|
+
|
|
736
|
+
Use this right after generating a site so the user gets a live, shareable URL
|
|
737
|
+
instead of loose files they would have to upload somewhere themselves. Works for
|
|
738
|
+
hand-written sites and for generator output — point it at a Docusaurus `build/`,
|
|
739
|
+
an Astro `dist/`, a Vite `dist/`, or a Next `out/` folder. HTML files become
|
|
740
|
+
pages (index.html -> /, about.html -> /about, docs/intro/index.html -> /docs/intro,
|
|
741
|
+
404.html -> the site's own not-found page); css/js/images/fonts become assets
|
|
742
|
+
served relative to the site root. Large builds are uploaded in several requests
|
|
743
|
+
automatically.
|
|
744
|
+
|
|
745
|
+
Docusaurus: the response reports the build's `base_url`. A build made with
|
|
746
|
+
baseUrl '/' only renders correctly once a custom domain is mapped to the site;
|
|
747
|
+
to preview at the returned thin.host URL, build with baseUrl '/s/<slug>/' (pass
|
|
748
|
+
that slug here). Any mismatch comes back as a `warning` — surface it to the user.
|
|
749
|
+
|
|
750
|
+
The account's API key must be created from the thin.host dashboard (Settings →
|
|
751
|
+
Create API key) so it is linked to a user account — error_code "account_not_linked"
|
|
752
|
+
means it isn't; surface that guidance rather than retrying.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
source_path: Absolute path to an .html file, a site/build directory, or a .zip.
|
|
756
|
+
title: Human-readable site title (shown in the dashboard and emails).
|
|
757
|
+
slug: Optional URL slug (site lives at /s/<slug>/). Auto-generated if omitted.
|
|
758
|
+
"slug_taken" means it's in use — pick another or omit.
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
url: The live site URL — share this with the user.
|
|
762
|
+
id, slug, title, page_count, asset_count, pages, assets: What was published.
|
|
763
|
+
framework, base_url: Detected generator (e.g. "docusaurus") and its baseUrl.
|
|
764
|
+
warning: Present when the build's baseUrl won't resolve at the returned url.
|
|
765
|
+
skipped_files: Local files ignored because their type isn't supported.
|
|
766
|
+
"""
|
|
767
|
+
try:
|
|
768
|
+
pages, assets, skipped = _collect_site_files(source_path)
|
|
769
|
+
except ValueError as e:
|
|
770
|
+
return _invalid_source(e, "publish_website")
|
|
771
|
+
return await _do_publish(pages, assets, skipped, title, slug)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
@mcp.tool()
|
|
775
|
+
async def update_website(
|
|
776
|
+
website: str,
|
|
777
|
+
source_path: str,
|
|
778
|
+
title: str | None = None,
|
|
779
|
+
sync: bool = False,
|
|
780
|
+
) -> dict:
|
|
781
|
+
"""Push updated pages/assets to an existing hosted website (upsert by path).
|
|
782
|
+
|
|
783
|
+
Files present locally replace the same paths on the site. With sync=false
|
|
784
|
+
(default) site files not in this upload are left untouched — right for editing
|
|
785
|
+
a few pages by hand. With sync=true the upload is treated as the complete site:
|
|
786
|
+
after it lands, every page and asset the site has that this upload does not is
|
|
787
|
+
deleted. Always use sync=true when redeploying a generator build folder
|
|
788
|
+
(Docusaurus, Astro, Vite, Next export) — each build renames its hashed chunks,
|
|
789
|
+
and without sync the old ones pile up forever. Dashboard-authored redirect
|
|
790
|
+
pages are kept regardless and listed in kept_redirects.
|
|
791
|
+
|
|
792
|
+
Use publish_website for a brand-new site.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
website: The site's id, slug, or live URL (e.g. "https://thin.host/s/my-site/").
|
|
796
|
+
source_path: Absolute path to an .html file, a site/build directory, or a .zip.
|
|
797
|
+
title: Optional new site title.
|
|
798
|
+
sync: Treat the upload as the whole site and remove everything else.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
url plus updated counts, and with sync=true the removed_pages / removed_assets.
|
|
802
|
+
framework, base_url, warning as in publish_website.
|
|
803
|
+
"""
|
|
804
|
+
try:
|
|
805
|
+
pages, assets, skipped = _collect_site_files(source_path)
|
|
806
|
+
except ValueError as e:
|
|
807
|
+
return _invalid_source(e, "update_website")
|
|
808
|
+
return await _do_update(website, pages, assets, skipped, title, sync)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# ---------------------------------------------------------------------------
|
|
812
|
+
# Publishing from a URL (the hosted endpoint's only publish path)
|
|
813
|
+
# ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
# The server fetches the URL itself, so it must refuse anything that would
|
|
816
|
+
# reach the network it sits on: loopback, RFC1918, link-local (the cloud
|
|
817
|
+
# metadata service lives at 169.254.169.254), and non-http schemes. Redirect
|
|
818
|
+
# hops are checked too. Tests flip the env var to fetch from a local server.
|
|
819
|
+
_ALLOW_PRIVATE_FETCH = os.environ.get("THINHOST_MCP_ALLOW_PRIVATE_URLS") == "1"
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _check_public_host(hostname: str):
|
|
823
|
+
if _ALLOW_PRIVATE_FETCH:
|
|
824
|
+
return
|
|
825
|
+
if not hostname:
|
|
826
|
+
raise ValueError("The URL has no host.")
|
|
827
|
+
try:
|
|
828
|
+
infos = socket.getaddrinfo(hostname, None)
|
|
829
|
+
except socket.gaierror:
|
|
830
|
+
raise ValueError(f"Cannot resolve host {hostname}.")
|
|
831
|
+
for info in infos:
|
|
832
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
833
|
+
if (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
|
|
834
|
+
or ip.is_multicast or ip.is_unspecified):
|
|
835
|
+
raise ValueError(
|
|
836
|
+
f"{hostname} resolves to a non-public address; only publicly reachable URLs can be fetched."
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _check_url(url: str):
|
|
841
|
+
parsed = urlparse(url)
|
|
842
|
+
if parsed.scheme not in ("http", "https"):
|
|
843
|
+
raise ValueError("source_url must start with http:// or https://.")
|
|
844
|
+
_check_public_host(parsed.hostname)
|
|
845
|
+
return parsed
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
async def _download_source(source_url: str):
|
|
849
|
+
"""Fetch a ZIP or HTML document. Returns (suffix, bytes); raises ValueError."""
|
|
850
|
+
_check_url(source_url)
|
|
851
|
+
async with httpx.AsyncClient(follow_redirects=True, timeout=120) as client:
|
|
852
|
+
async with client.stream("GET", source_url) as resp:
|
|
853
|
+
for hop in list(resp.history) + [resp]:
|
|
854
|
+
_check_url(str(hop.url))
|
|
855
|
+
if resp.status_code >= 400:
|
|
856
|
+
raise ValueError(f"Fetching {source_url} returned HTTP {resp.status_code}.")
|
|
857
|
+
declared = int(resp.headers.get("content-length") or 0)
|
|
858
|
+
if declared > _MAX_SITE_BYTES:
|
|
859
|
+
raise ValueError(f"Source is {declared // (1024 * 1024)}MB; the limit is "
|
|
860
|
+
f"{_MAX_SITE_BYTES // (1024 * 1024)}MB.")
|
|
861
|
+
chunks, total = [], 0
|
|
862
|
+
async for chunk in resp.aiter_bytes():
|
|
863
|
+
total += len(chunk)
|
|
864
|
+
if total > _MAX_SITE_BYTES:
|
|
865
|
+
raise ValueError(f"Source exceeds {_MAX_SITE_BYTES // (1024 * 1024)}MB.")
|
|
866
|
+
chunks.append(chunk)
|
|
867
|
+
content_type = resp.headers.get("content-type", "").lower()
|
|
868
|
+
final_path = urlparse(str(resp.url)).path.lower()
|
|
869
|
+
data = b"".join(chunks)
|
|
870
|
+
if data[:4] == b"PK\x03\x04":
|
|
871
|
+
return ".zip", data
|
|
872
|
+
if "text/html" in content_type or final_path.endswith((".html", ".htm")):
|
|
873
|
+
return ".html", data
|
|
874
|
+
raise ValueError(
|
|
875
|
+
"The URL did not return a ZIP archive or an HTML document. Point source_url at a "
|
|
876
|
+
".zip of the site (e.g. the build folder zipped) or a single .html file."
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
async def _collect_from_url(source_url: str):
|
|
881
|
+
suffix, data = await _download_source(source_url)
|
|
882
|
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as fh:
|
|
883
|
+
fh.write(data)
|
|
884
|
+
tmp = fh.name
|
|
885
|
+
try:
|
|
886
|
+
return _collect_site_files(tmp)
|
|
887
|
+
finally:
|
|
888
|
+
try:
|
|
889
|
+
os.unlink(tmp)
|
|
890
|
+
except OSError:
|
|
891
|
+
pass
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
@mcp.tool()
|
|
895
|
+
async def publish_website_from_url(
|
|
896
|
+
source_url: str,
|
|
897
|
+
title: str,
|
|
898
|
+
slug: str | None = None,
|
|
899
|
+
) -> dict:
|
|
900
|
+
"""Publish a site from a URL — a .zip of the site/build folder, or a single HTML page.
|
|
901
|
+
|
|
902
|
+
The hosted thin.host MCP endpoint cannot read your disk, so this is its publish
|
|
903
|
+
path: zip the build folder (`cd build && zip -r ../site.zip .`), put the zip
|
|
904
|
+
somewhere publicly reachable (a release asset, object storage, a slim.to link),
|
|
905
|
+
and pass that URL. Everything else — page/asset mapping, batching, Docusaurus
|
|
906
|
+
baseUrl detection and the `warning` it may produce — matches publish_website.
|
|
907
|
+
Private, loopback and cloud-metadata addresses are refused.
|
|
908
|
+
|
|
909
|
+
Args:
|
|
910
|
+
source_url: Public http(s) URL of a .zip archive or an .html file (≤150MB).
|
|
911
|
+
title: Human-readable site title.
|
|
912
|
+
slug: Optional URL slug (site lives at /s/<slug>/).
|
|
913
|
+
|
|
914
|
+
Returns:
|
|
915
|
+
Same shape as publish_website.
|
|
916
|
+
"""
|
|
917
|
+
try:
|
|
918
|
+
pages, assets, skipped = await _collect_from_url(source_url)
|
|
919
|
+
except (ValueError, httpx.HTTPError) as e:
|
|
920
|
+
return _invalid_source(e, "publish_website_from_url")
|
|
921
|
+
return await _do_publish(pages, assets, skipped, title, slug)
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@mcp.tool()
|
|
925
|
+
async def update_website_from_url(
|
|
926
|
+
website: str,
|
|
927
|
+
source_url: str,
|
|
928
|
+
title: str | None = None,
|
|
929
|
+
sync: bool = True,
|
|
930
|
+
) -> dict:
|
|
931
|
+
"""Redeploy an existing site from a URL (.zip of the site, or a single HTML page).
|
|
932
|
+
|
|
933
|
+
The URL form of update_website, for the hosted endpoint or any agent that has the
|
|
934
|
+
build as a downloadable archive. sync defaults to true here because a zipped build
|
|
935
|
+
is the whole site: after upload, files the archive does not contain are deleted
|
|
936
|
+
(dashboard-authored redirect pages are kept). Pass sync=false to upsert only.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
website: The site's id, slug, or live URL.
|
|
940
|
+
source_url: Public http(s) URL of a .zip archive or an .html file (≤150MB).
|
|
941
|
+
title: Optional new site title.
|
|
942
|
+
sync: Treat the archive as the whole site (default true).
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
Same shape as update_website.
|
|
946
|
+
"""
|
|
947
|
+
try:
|
|
948
|
+
pages, assets, skipped = await _collect_from_url(source_url)
|
|
949
|
+
except (ValueError, httpx.HTTPError) as e:
|
|
950
|
+
return _invalid_source(e, "update_website_from_url")
|
|
951
|
+
return await _do_update(website, pages, assets, skipped, title, sync)
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
@mcp.tool()
|
|
955
|
+
async def list_websites(limit: int = 50) -> dict:
|
|
956
|
+
"""List the account's hosted websites with their live URLs, newest first.
|
|
957
|
+
|
|
958
|
+
Args:
|
|
959
|
+
limit: Max sites to return (default 50, max 100).
|
|
960
|
+
"""
|
|
961
|
+
async with _client() as client:
|
|
962
|
+
resp = await client.get(
|
|
963
|
+
f"{API_BASE_URL}/websites",
|
|
964
|
+
headers=_headers(),
|
|
965
|
+
params={"limit": limit},
|
|
966
|
+
timeout=30,
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
data = resp.json()
|
|
970
|
+
if resp.status_code >= 400:
|
|
971
|
+
return _format_error(data)
|
|
972
|
+
|
|
973
|
+
return {
|
|
974
|
+
"websites": [
|
|
975
|
+
{
|
|
976
|
+
"id": w["id"],
|
|
977
|
+
"slug": w["slug"],
|
|
978
|
+
"title": w["title"],
|
|
979
|
+
"url": w["url"],
|
|
980
|
+
"page_count": w["page_count"],
|
|
981
|
+
"asset_count": w["asset_count"],
|
|
982
|
+
"custom_domains": w["custom_domains"],
|
|
983
|
+
"updated_at": w["updated_at"],
|
|
984
|
+
}
|
|
985
|
+
for w in data.get("websites", [])
|
|
986
|
+
],
|
|
987
|
+
"total": data.get("total", 0),
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def main():
|
|
992
|
+
"""Console entry point (`thinhost-mcp`): serve the tools over stdio."""
|
|
993
|
+
mcp.run()
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
if __name__ == "__main__":
|
|
997
|
+
main()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: thinhost-mcp
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: thin.host MCP server — publish sites, custom domains and transactional email for AI agents
|
|
5
|
+
Home-page: https://thin.host
|
|
6
|
+
Author: thin.host
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: mcp>=1.9,<2
|
|
11
|
+
Requires-Dist: httpx>=0.27.0
|
|
12
|
+
|
|
13
|
+
# thinhost-mcp
|
|
14
|
+
|
|
15
|
+
The thin.host MCP server as a console script. Run it with an API key from the
|
|
16
|
+
thin.host dashboard (Settings → Create API key):
|
|
17
|
+
|
|
18
|
+
THIN_HOST_API_KEY=th_live_... THINHOST_API_URL=https://thin.host/v1 thinhost-mcp
|
|
19
|
+
|
|
20
|
+
Claude Code:
|
|
21
|
+
|
|
22
|
+
claude mcp add thin-host -e THINHOST_API_URL=https://thin.host/v1 -e THIN_HOST_API_KEY=th_live_... -- uvx thinhost-mcp
|
|
23
|
+
|
|
24
|
+
Prefer no install at all? The same tools are hosted at https://thin.host/mcp
|
|
25
|
+
(bearer API key or OAuth). Docs: https://thin.host/docs/mcp
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
thinhost_mcp/__init__.py,sha256=LGVQyDsWifdACo7qztwb8RWWHds1E7uQ-ZqD8SAjyw4,22
|
|
2
|
+
thinhost_mcp/server.py,sha256=WX7W1D7aI85t937r6zXoXKph65OlXBgNbPJRgiKtYPA,38701
|
|
3
|
+
thinhost_mcp-1.1.0.dist-info/METADATA,sha256=VFmsMxgFcgwiJA2MM-kHx86TU8FLYIz9r3q9JmI-ejw,846
|
|
4
|
+
thinhost_mcp-1.1.0.dist-info/WHEEL,sha256=F_8nXytLCRmZidw4XlphTE6Hrmgg2f_ecTHb1djbLtg,79
|
|
5
|
+
thinhost_mcp-1.1.0.dist-info/entry_points.txt,sha256=754slrw7yC0E-LH6P-qoxbFCjNwnArgacgLbEFDmr04,58
|
|
6
|
+
thinhost_mcp-1.1.0.dist-info/top_level.txt,sha256=wK2KhcCdz0e7oMP_VBro2MqLg9q_4l6LaALDh6MlKWA,13
|
|
7
|
+
thinhost_mcp-1.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
thinhost_mcp
|