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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: thinhost
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ thinhost-mcp = thinhost_mcp.server:main
@@ -0,0 +1 @@
1
+ thinhost_mcp