htmlship 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.
- htmlship/__init__.py +101 -0
- htmlship/_version.py +1 -0
- htmlship/cli.py +302 -0
- htmlship/client.py +212 -0
- htmlship/exceptions.py +29 -0
- htmlship/models.py +61 -0
- htmlship-0.1.0.dist-info/METADATA +292 -0
- htmlship-0.1.0.dist-info/RECORD +29 -0
- htmlship-0.1.0.dist-info/WHEEL +4 -0
- htmlship-0.1.0.dist-info/entry_points.txt +3 -0
- htmlship_mcp/__init__.py +1 -0
- htmlship_mcp/server.py +123 -0
- htmlship_server/__init__.py +1 -0
- htmlship_server/config.py +62 -0
- htmlship_server/database.py +38 -0
- htmlship_server/db_models/__init__.py +3 -0
- htmlship_server/db_models/paste.py +60 -0
- htmlship_server/exceptions.py +9 -0
- htmlship_server/main.py +69 -0
- htmlship_server/middleware.py +132 -0
- htmlship_server/routers/__init__.py +0 -0
- htmlship_server/routers/meta.py +17 -0
- htmlship_server/routers/pastes.py +234 -0
- htmlship_server/routers/view.py +175 -0
- htmlship_server/schemas/__init__.py +0 -0
- htmlship_server/schemas/pastes.py +48 -0
- htmlship_server/security.py +49 -0
- htmlship_server/slugs.py +36 -0
- htmlship_server/storage.py +137 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: htmlship
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Host and share HTML pages from LLMs and coding agents in one line.
|
|
5
|
+
Project-URL: Homepage, https://htmlship.com
|
|
6
|
+
Project-URL: Repository, https://github.com/htmlship/htmlship
|
|
7
|
+
Author: HTMLShip
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: agent,hosting,html,llm,mcp,paste
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: click<9,>=8.1
|
|
17
|
+
Requires-Dist: httpx<0.28,>=0.27
|
|
18
|
+
Provides-Extra: cli
|
|
19
|
+
Requires-Dist: pyperclip<2,>=1.9; extra == 'cli'
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: aiosqlite>=0.22.1; extra == 'dev'
|
|
22
|
+
Requires-Dist: greenlet>=3.5.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest-asyncio<0.25,>=0.24; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-cov<6,>=5; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff<0.8,>=0.7; extra == 'dev'
|
|
27
|
+
Provides-Extra: mcp
|
|
28
|
+
Requires-Dist: mcp<2,>=1.0; extra == 'mcp'
|
|
29
|
+
Provides-Extra: server
|
|
30
|
+
Requires-Dist: alembic<2,>=1.13; extra == 'server'
|
|
31
|
+
Requires-Dist: argon2-cffi<24,>=23; extra == 'server'
|
|
32
|
+
Requires-Dist: asyncpg<0.31,>=0.30; extra == 'server'
|
|
33
|
+
Requires-Dist: boto3<2,>=1.35; extra == 'server'
|
|
34
|
+
Requires-Dist: fastapi<0.116,>=0.115; extra == 'server'
|
|
35
|
+
Requires-Dist: itsdangerous<3,>=2.2; extra == 'server'
|
|
36
|
+
Requires-Dist: nanoid<3,>=2; extra == 'server'
|
|
37
|
+
Requires-Dist: pydantic-settings<3,>=2.5; extra == 'server'
|
|
38
|
+
Requires-Dist: pydantic<3,>=2; extra == 'server'
|
|
39
|
+
Requires-Dist: python-multipart<0.1,>=0.0.12; extra == 'server'
|
|
40
|
+
Requires-Dist: slowapi<0.2,>=0.1.9; extra == 'server'
|
|
41
|
+
Requires-Dist: sqlalchemy<3,>=2.0; extra == 'server'
|
|
42
|
+
Requires-Dist: uvicorn[standard]<0.33,>=0.32; extra == 'server'
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# HTMLShip
|
|
46
|
+
|
|
47
|
+
Host and share HTML pages from LLMs and coding agents in one line.
|
|
48
|
+
|
|
49
|
+
HTMLShip has three surfaces:
|
|
50
|
+
|
|
51
|
+
- a public Python library and CLI (`htmlship`)
|
|
52
|
+
- a FastAPI service for creating, updating, deleting, and viewing HTML pastes
|
|
53
|
+
- a stdio MCP server (`htmlship-mcp`) for agent clients
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install htmlship
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import htmlship
|
|
61
|
+
|
|
62
|
+
paste = htmlship.publish("<h1>Hello</h1>", title="Demo", expires_in=3600)
|
|
63
|
+
print(paste.url)
|
|
64
|
+
print(paste.owner_key) # save this to update or delete the paste later
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
curl -X POST https://api.htmlship.com/api/v1/pastes \
|
|
69
|
+
-H "Content-Type: application/json" \
|
|
70
|
+
-d '{"html":"<h1>Hello</h1>","title":"Demo"}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
See [`htmlship-implementation-spec.md`](./htmlship-implementation-spec.md) for the product spec and [`DEPLOY.md`](./DEPLOY.md) for the production runbook.
|
|
74
|
+
|
|
75
|
+
## Python Library
|
|
76
|
+
|
|
77
|
+
The module-level helpers use `https://api.htmlship.com` by default. Override with `HTMLSHIP_API_URL` or `htmlship.configure(base_url=...)`.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import htmlship
|
|
81
|
+
|
|
82
|
+
paste = htmlship.publish(
|
|
83
|
+
"<h1>Hello</h1>",
|
|
84
|
+
title="Demo",
|
|
85
|
+
password="optional-password",
|
|
86
|
+
expires_in=86400,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
fresh = htmlship.get(paste.slug)
|
|
90
|
+
updated = htmlship.update(paste.slug, "<h1>Updated</h1>", owner_key=paste.owner_key)
|
|
91
|
+
htmlship.delete(updated.slug, owner_key=paste.owner_key)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`owner_key` is returned only when a paste is created. It is required for updates and deletes, and the API does not return it again from metadata calls.
|
|
95
|
+
|
|
96
|
+
## CLI
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
htmlship publish report.html
|
|
100
|
+
cat report.html | htmlship publish -
|
|
101
|
+
htmlship publish --file report.html --title "Q4 Report" --expires-in 3600
|
|
102
|
+
|
|
103
|
+
htmlship get <slug>
|
|
104
|
+
htmlship update <slug> updated.html
|
|
105
|
+
htmlship delete <slug>
|
|
106
|
+
htmlship list-mine
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The CLI stores owner keys in `~/.htmlship/keys.json` so `update`, `delete`, and `list-mine` can work with pastes you created locally. Set `HTMLSHIP_KEYS_DIR` to use another key-store directory, and set `HTMLSHIP_API_URL` to point the CLI at a local or staging API.
|
|
110
|
+
|
|
111
|
+
## API
|
|
112
|
+
|
|
113
|
+
Base URL: `https://api.htmlship.com`.
|
|
114
|
+
|
|
115
|
+
| Method | Path | Description |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `GET` | `/health` | Health check with service version. |
|
|
118
|
+
| `GET` | `/version` | Service version. |
|
|
119
|
+
| `POST` | `/api/v1/pastes` | Create a paste. |
|
|
120
|
+
| `GET` | `/api/v1/pastes/{slug}` | Fetch paste metadata. |
|
|
121
|
+
| `PATCH` | `/api/v1/pastes/{slug}` | Replace HTML or title. Requires `X-Owner-Key`. |
|
|
122
|
+
| `DELETE` | `/api/v1/pastes/{slug}` | Soft-delete a paste. Requires `X-Owner-Key`. |
|
|
123
|
+
| `POST` | `/api/v1/pastes/{slug}/version` | Create a new paste linked to an existing parent slug. |
|
|
124
|
+
|
|
125
|
+
Create payload:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"html": "<h1>Hello</h1>",
|
|
130
|
+
"title": "Optional title",
|
|
131
|
+
"password": "optional password",
|
|
132
|
+
"expires_in": 3600,
|
|
133
|
+
"parent_slug": "optional-parent",
|
|
134
|
+
"sandbox_mode": "strict"
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Notes:
|
|
139
|
+
|
|
140
|
+
- `html` is stored and served verbatim. Scripts are blocked by the view CSP, not by sanitizing the body.
|
|
141
|
+
- Payloads are limited to 10 MiB by default.
|
|
142
|
+
- `expires_in` is in seconds and must be between 60 seconds and 365 days.
|
|
143
|
+
- `sandbox_mode` accepts `strict` or `relaxed`; the current view headers use the strict CSP.
|
|
144
|
+
- Password-protected views set a signed, HTTP-only session cookie after the correct password is submitted.
|
|
145
|
+
|
|
146
|
+
## Rendering
|
|
147
|
+
|
|
148
|
+
Rendered HTML is served from `view.htmlship.com/{slug}` with strict security headers:
|
|
149
|
+
|
|
150
|
+
- `Content-Security-Policy: default-src 'none'`
|
|
151
|
+
- images from `data:` and `https:`
|
|
152
|
+
- inline styles plus HTTPS stylesheets
|
|
153
|
+
- HTTPS/data fonts and HTTPS media
|
|
154
|
+
- `X-Content-Type-Options: nosniff`
|
|
155
|
+
- `Referrer-Policy: no-referrer`
|
|
156
|
+
- restrictive `Permissions-Policy`
|
|
157
|
+
|
|
158
|
+
The app routes by `Host` header:
|
|
159
|
+
|
|
160
|
+
- `htmlship.com` serves the landing page
|
|
161
|
+
- `api.htmlship.com` serves the API and landing assets
|
|
162
|
+
- `view.htmlship.com/{slug}` serves sandboxed HTML
|
|
163
|
+
|
|
164
|
+
For local development without DNS, append `?_host=view.htmlship.com` (or your configured view host) to spoof the host header, for example:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
curl "http://localhost:8000/<slug>?_host=view.htmlship.com"
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## MCP Server
|
|
171
|
+
|
|
172
|
+
`htmlship-mcp` is a stdio MCP server exposing three tools:
|
|
173
|
+
|
|
174
|
+
- `publish_html`
|
|
175
|
+
- `fetch_html`
|
|
176
|
+
- `update_html`
|
|
177
|
+
|
|
178
|
+
All HTTP traffic goes through the public Python client. Configure the endpoint with `HTMLSHIP_API_URL`.
|
|
179
|
+
|
|
180
|
+
### Claude Desktop
|
|
181
|
+
|
|
182
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows:
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"mcpServers": {
|
|
187
|
+
"htmlship": {
|
|
188
|
+
"command": "htmlship-mcp",
|
|
189
|
+
"env": {
|
|
190
|
+
"HTMLSHIP_API_URL": "https://api.htmlship.com"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Restart Claude Desktop. Then ask: `publish this HTML: <h1>test</h1>`.
|
|
198
|
+
|
|
199
|
+
### Claude Code
|
|
200
|
+
|
|
201
|
+
Edit `~/.claude.json` or use `claude mcp add`:
|
|
202
|
+
|
|
203
|
+
```json
|
|
204
|
+
{
|
|
205
|
+
"mcpServers": {
|
|
206
|
+
"htmlship": {
|
|
207
|
+
"type": "stdio",
|
|
208
|
+
"command": "htmlship-mcp",
|
|
209
|
+
"env": {
|
|
210
|
+
"HTMLSHIP_API_URL": "https://api.htmlship.com"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Local Development
|
|
218
|
+
|
|
219
|
+
Requirements:
|
|
220
|
+
|
|
221
|
+
- Python 3.12+
|
|
222
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
223
|
+
- Docker
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
# 1. Install dependencies (creates .venv)
|
|
227
|
+
uv sync --extra server --extra cli --extra mcp --extra dev
|
|
228
|
+
|
|
229
|
+
# 2. Start Postgres on localhost:5433
|
|
230
|
+
docker compose up -d postgres
|
|
231
|
+
|
|
232
|
+
# 3. Copy env and edit if needed
|
|
233
|
+
cp .env.example .env
|
|
234
|
+
|
|
235
|
+
# 4. Run migrations
|
|
236
|
+
uv run alembic upgrade head
|
|
237
|
+
|
|
238
|
+
# 5. Start the server
|
|
239
|
+
uv run uvicorn htmlship_server.main:app --reload
|
|
240
|
+
|
|
241
|
+
# 6. Health check
|
|
242
|
+
curl http://localhost:8000/health
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Run tests and linting:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
uv run pytest
|
|
249
|
+
uv run ruff check .
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
The test suite uses SQLite and a temporary local blob store. Local development uses Postgres metadata plus `./tmp/blobs/` for HTML blobs unless `SPACES_BUCKET` is configured.
|
|
253
|
+
|
|
254
|
+
## Configuration
|
|
255
|
+
|
|
256
|
+
The server reads `.env` via Pydantic settings.
|
|
257
|
+
|
|
258
|
+
| Variable | Default | Purpose |
|
|
259
|
+
| --- | --- | --- |
|
|
260
|
+
| `DATABASE_URL` | `postgresql+asyncpg://htmlship:htmlship@localhost:5433/htmlship` | Async SQLAlchemy database URL. |
|
|
261
|
+
| `PUBLIC_BASE_DOMAIN` | `htmlship.com` | Base domain used to derive host routing. |
|
|
262
|
+
| `API_BASE_URL` | `https://api.htmlship.com` | Public API URL setting. |
|
|
263
|
+
| `VIEW_BASE_URL` | `https://view.htmlship.com` | Public view URL used in paste responses. |
|
|
264
|
+
| `LANDING_BASE_URL` | `https://htmlship.com` | Public landing URL. |
|
|
265
|
+
| `SPACES_BUCKET` | empty | If empty, use local blob storage; otherwise use DigitalOcean Spaces/S3. |
|
|
266
|
+
| `SPACES_REGION` | `nyc3` | Spaces/S3 region. |
|
|
267
|
+
| `SPACES_ENDPOINT_URL` | `https://nyc3.digitaloceanspaces.com` | Spaces/S3 endpoint. |
|
|
268
|
+
| `SPACES_ACCESS_KEY` / `SPACES_SECRET_KEY` | empty | Spaces/S3 credentials. |
|
|
269
|
+
| `SECRET_KEY` | development placeholder | Signs password-view session cookies. Use a strong value in production. |
|
|
270
|
+
| `ENVIRONMENT` | `development` | Enables API docs outside production and secure cookies in production. |
|
|
271
|
+
| `LOG_LEVEL` | `info` | Application log level. |
|
|
272
|
+
| `MAX_PAYLOAD_BYTES` | `10485760` | Server-side HTML size limit. |
|
|
273
|
+
| `DEFAULT_EXPIRES_IN_SECONDS` | empty | Optional default TTL for new pastes. |
|
|
274
|
+
|
|
275
|
+
## Architecture
|
|
276
|
+
|
|
277
|
+
One FastAPI process hosts the landing page, JSON API, and view renderer. `HostRoutingMiddleware` classifies requests by host and prevents API routes from being served on the view host.
|
|
278
|
+
|
|
279
|
+
Postgres stores paste metadata, owner-key/password hashes, expiry, view counts, and parent-version links. HTML bodies are stored as blobs, either in `LocalBlobStore` for development/tests or DigitalOcean Spaces in production.
|
|
280
|
+
|
|
281
|
+
## Project Layout
|
|
282
|
+
|
|
283
|
+
```text
|
|
284
|
+
src/htmlship/ Public Python library + CLI
|
|
285
|
+
src/htmlship_server/ FastAPI app, API routers, storage, database models
|
|
286
|
+
src/htmlship_mcp/ MCP server (stdio)
|
|
287
|
+
web/ Static landing page
|
|
288
|
+
tests/ API, client, CLI, MCP, landing, and view tests
|
|
289
|
+
alembic/ Database migrations
|
|
290
|
+
deploy/ Production configs (nginx, systemd)
|
|
291
|
+
scripts/ Deploy and smoke-test scripts
|
|
292
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
htmlship/__init__.py,sha256=XnwrjZOXH58DxoOo6CobfD0WfdxOmprDeGdOxqbBIlo,2360
|
|
2
|
+
htmlship/_version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
3
|
+
htmlship/cli.py,sha256=JwOMhqbxltApQbXP-sPnx3gmS9FlyquAE_bmcESAC7Y,9249
|
|
4
|
+
htmlship/client.py,sha256=eSjvdTiS94gwG_-jZwAYzRsrAwzP14DdwTdZQwwTCfY,6665
|
|
5
|
+
htmlship/exceptions.py,sha256=9P85j5FpjqT4s0z4fErCHfTnguWvbBXuv0UM3MtzjLc,775
|
|
6
|
+
htmlship/models.py,sha256=OdRhEshJZF6rXrprS4QcmfMtiYtnkaIUNlf0FkKwRYU,2238
|
|
7
|
+
htmlship_mcp/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
8
|
+
htmlship_mcp/server.py,sha256=GGYQ08X23HuPRaxyYtcsfHo5l6WEYZDVmS9rz_gtg4E,4059
|
|
9
|
+
htmlship_server/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
10
|
+
htmlship_server/config.py,sha256=1wIeco_m22Kve-3Jt3h310MotfxJCf4a50FjfI2zHnw,1643
|
|
11
|
+
htmlship_server/database.py,sha256=WRBpVqYJmFkXMVniGDiaX9j5HTJDoUxHnZSftqxF7qw,832
|
|
12
|
+
htmlship_server/exceptions.py,sha256=fRR0Dz5PrFvrE8CfM5q53KED-1pr8VIGZ43ECW05-V0,160
|
|
13
|
+
htmlship_server/main.py,sha256=NZ95It_GSQEwuhv9THAjfdvN5PXL506NXo0BoKbUbF0,2218
|
|
14
|
+
htmlship_server/middleware.py,sha256=ZsCkv_AdSsU9hvl8HIHyna04VbZApyre-kJRMj-zfdI,4504
|
|
15
|
+
htmlship_server/security.py,sha256=DwPiC1VORPGnzjOhy8DtB0dWfDEbJQsdDwRMNfIGBmA,1037
|
|
16
|
+
htmlship_server/slugs.py,sha256=Gylvapqka6SSxfYkC1ZJEaQu5NKUtfTsgzHtTMDF__Q,1040
|
|
17
|
+
htmlship_server/storage.py,sha256=Mv7W2xCH9mlPzzshkU4aG8ArlO65YfKUR9hYrOGmLnI,3875
|
|
18
|
+
htmlship_server/db_models/__init__.py,sha256=gLN65iq3xYnEw9Rvhre88i3gw8QBMJMda_33sYncKDo,60
|
|
19
|
+
htmlship_server/db_models/paste.py,sha256=roX2MLMpZDoFNoaluB9AnbrutaiqVzVyFrbHlUSVdVk,2246
|
|
20
|
+
htmlship_server/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
21
|
+
htmlship_server/routers/meta.py,sha256=axzAEOLxsD5gpCxrrXBDEUS6HEj2rGSdpT60F_9e_Q8,332
|
|
22
|
+
htmlship_server/routers/pastes.py,sha256=bLHWCc4C3do0NVNpRc96BpZGvQTuCjlmGoMYy4hn5YQ,7207
|
|
23
|
+
htmlship_server/routers/view.py,sha256=ZSDTK_qu59JVLhmjnA_LMCzlxTdeYtAn2gr8K5lEFBg,5582
|
|
24
|
+
htmlship_server/schemas/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
htmlship_server/schemas/pastes.py,sha256=4rqa31YzW7ezSWKme59Sq8GbyAnrqC2dRJo4PSVrkAs,1258
|
|
26
|
+
htmlship-0.1.0.dist-info/METADATA,sha256=PC0ju57I3jE3InMEhWnEIqgfVdJR_Znz8GHnGkloXTU,9664
|
|
27
|
+
htmlship-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
28
|
+
htmlship-0.1.0.dist-info/entry_points.txt,sha256=zsk7_AdOG5WKc_UCBw_BmBBEGC0ynruYWWjk_UlEmd0,87
|
|
29
|
+
htmlship-0.1.0.dist-info/RECORD,,
|
htmlship_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
htmlship_mcp/server.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""HTMLShip MCP server.
|
|
2
|
+
|
|
3
|
+
Exposes three tools — publish_html, fetch_html, update_html — that wrap the
|
|
4
|
+
public Python library. All HTTP traffic goes through the library and the
|
|
5
|
+
real API; this server adds no business logic of its own.
|
|
6
|
+
|
|
7
|
+
Configure the API endpoint via the HTMLSHIP_API_URL environment variable.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
|
|
16
|
+
import htmlship
|
|
17
|
+
from htmlship.exceptions import AuthError, HTMLShipError, NotFoundError
|
|
18
|
+
|
|
19
|
+
mcp = FastMCP("htmlship")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _client() -> htmlship.HTMLShipClient:
|
|
23
|
+
base_url = os.getenv("HTMLSHIP_API_URL")
|
|
24
|
+
return htmlship.HTMLShipClient(base_url=base_url)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@mcp.tool()
|
|
28
|
+
def publish_html(
|
|
29
|
+
html: str,
|
|
30
|
+
title: str | None = None,
|
|
31
|
+
expires_in_seconds: int | None = None,
|
|
32
|
+
) -> dict[str, Any]:
|
|
33
|
+
"""Publish an HTML document and get a public URL.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
html: The HTML body to publish. May include inline CSS but scripts will
|
|
37
|
+
be blocked by Content Security Policy at view time.
|
|
38
|
+
title: Optional human-readable title.
|
|
39
|
+
expires_in_seconds: Optional TTL. If omitted, the paste is permanent
|
|
40
|
+
(subject to the server's default policy).
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
A dict with keys: url, slug, owner_key, expires_at.
|
|
44
|
+
IMPORTANT: owner_key is the only credential to update or delete the
|
|
45
|
+
paste later. Save it.
|
|
46
|
+
"""
|
|
47
|
+
with _client() as c:
|
|
48
|
+
try:
|
|
49
|
+
paste = c.publish(html, title=title, expires_in=expires_in_seconds)
|
|
50
|
+
except HTMLShipError as exc:
|
|
51
|
+
raise RuntimeError(f"htmlship publish failed: {exc}") from exc
|
|
52
|
+
return {
|
|
53
|
+
"url": paste.url,
|
|
54
|
+
"slug": paste.slug,
|
|
55
|
+
"owner_key": paste.owner_key,
|
|
56
|
+
"expires_at": paste.expires_at.isoformat() if paste.expires_at else None,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@mcp.tool()
|
|
61
|
+
def fetch_html(slug: str) -> dict[str, Any]:
|
|
62
|
+
"""Fetch a paste's metadata.
|
|
63
|
+
|
|
64
|
+
NOTE: this returns metadata only. To view the rendered HTML, open the
|
|
65
|
+
`url` in a browser — the content is served from a sandboxed subdomain.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
slug: The paste's short identifier.
|
|
69
|
+
"""
|
|
70
|
+
with _client() as c:
|
|
71
|
+
try:
|
|
72
|
+
paste = c.get(slug)
|
|
73
|
+
except NotFoundError as exc:
|
|
74
|
+
raise RuntimeError(f"paste '{slug}' not found") from exc
|
|
75
|
+
except HTMLShipError as exc:
|
|
76
|
+
raise RuntimeError(f"htmlship fetch failed: {exc}") from exc
|
|
77
|
+
return {
|
|
78
|
+
"slug": paste.slug,
|
|
79
|
+
"url": paste.url,
|
|
80
|
+
"title": paste.title,
|
|
81
|
+
"view_count": paste.view_count,
|
|
82
|
+
"size_bytes": paste.size_bytes,
|
|
83
|
+
"has_password": paste.has_password,
|
|
84
|
+
"parent_slug": paste.parent_slug,
|
|
85
|
+
"expires_at": paste.expires_at.isoformat() if paste.expires_at else None,
|
|
86
|
+
"created_at": paste.created_at.isoformat() if paste.created_at else None,
|
|
87
|
+
"updated_at": paste.updated_at.isoformat() if paste.updated_at else None,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mcp.tool()
|
|
92
|
+
def update_html(slug: str, html: str, owner_key: str, title: str | None = None) -> dict[str, Any]:
|
|
93
|
+
"""Replace the HTML for an existing paste. Requires the original owner_key.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
slug: The paste's short identifier.
|
|
97
|
+
html: The new HTML body.
|
|
98
|
+
owner_key: The owner key returned at publish time.
|
|
99
|
+
title: Optional new title.
|
|
100
|
+
"""
|
|
101
|
+
with _client() as c:
|
|
102
|
+
try:
|
|
103
|
+
paste = c.update(slug, html, owner_key=owner_key, title=title)
|
|
104
|
+
except AuthError as exc:
|
|
105
|
+
raise RuntimeError(f"invalid owner_key for '{slug}'") from exc
|
|
106
|
+
except NotFoundError as exc:
|
|
107
|
+
raise RuntimeError(f"paste '{slug}' not found") from exc
|
|
108
|
+
except HTMLShipError as exc:
|
|
109
|
+
raise RuntimeError(f"htmlship update failed: {exc}") from exc
|
|
110
|
+
return {
|
|
111
|
+
"url": paste.url,
|
|
112
|
+
"slug": paste.slug,
|
|
113
|
+
"updated_at": paste.updated_at.isoformat() if paste.updated_at else None,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> None:
|
|
118
|
+
"""Entry point for the `htmlship-mcp` console script (stdio transport)."""
|
|
119
|
+
mcp.run()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
|
|
5
|
+
from pydantic import Field, field_validator
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Settings(BaseSettings):
|
|
10
|
+
model_config = SettingsConfigDict(
|
|
11
|
+
env_file=".env",
|
|
12
|
+
env_file_encoding="utf-8",
|
|
13
|
+
extra="ignore",
|
|
14
|
+
case_sensitive=False,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
environment: str = "development"
|
|
18
|
+
log_level: str = "info"
|
|
19
|
+
|
|
20
|
+
database_url: str = Field(
|
|
21
|
+
default="postgresql+asyncpg://htmlship:htmlship@localhost:5433/htmlship"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
public_base_domain: str = "htmlship.com"
|
|
25
|
+
api_base_url: str = "https://api.htmlship.com"
|
|
26
|
+
view_base_url: str = "https://view.htmlship.com"
|
|
27
|
+
landing_base_url: str = "https://htmlship.com"
|
|
28
|
+
|
|
29
|
+
spaces_bucket: str = ""
|
|
30
|
+
spaces_region: str = "nyc3"
|
|
31
|
+
spaces_endpoint_url: str = "https://nyc3.digitaloceanspaces.com"
|
|
32
|
+
spaces_access_key: str = ""
|
|
33
|
+
spaces_secret_key: str = ""
|
|
34
|
+
|
|
35
|
+
secret_key: str = "dev-secret-do-not-use-in-prod"
|
|
36
|
+
|
|
37
|
+
max_payload_bytes: int = 10 * 1024 * 1024
|
|
38
|
+
default_expires_in_seconds: int | None = None
|
|
39
|
+
|
|
40
|
+
@field_validator("default_expires_in_seconds", mode="before")
|
|
41
|
+
@classmethod
|
|
42
|
+
def _empty_to_none(cls, v):
|
|
43
|
+
if v == "" or v is None:
|
|
44
|
+
return None
|
|
45
|
+
return v
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def use_local_blob_store(self) -> bool:
|
|
49
|
+
return not self.spaces_bucket
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def view_host(self) -> str:
|
|
53
|
+
return f"view.{self.public_base_domain}"
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def api_host(self) -> str:
|
|
57
|
+
return f"api.{self.public_base_domain}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@lru_cache
|
|
61
|
+
def get_settings() -> Settings:
|
|
62
|
+
return Settings()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.ext.asyncio import (
|
|
6
|
+
AsyncSession,
|
|
7
|
+
async_sessionmaker,
|
|
8
|
+
create_async_engine,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
from .config import get_settings
|
|
12
|
+
|
|
13
|
+
_settings = get_settings()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_engine(url: str):
|
|
17
|
+
kwargs: dict = {"echo": False}
|
|
18
|
+
if "sqlite" not in url:
|
|
19
|
+
kwargs.update(pool_size=20, max_overflow=10, pool_pre_ping=True)
|
|
20
|
+
return create_async_engine(url, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
engine = _build_engine(_settings.database_url)
|
|
24
|
+
|
|
25
|
+
SessionLocal = async_sessionmaker(
|
|
26
|
+
engine,
|
|
27
|
+
expire_on_commit=False,
|
|
28
|
+
class_=AsyncSession,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def get_session() -> AsyncIterator[AsyncSession]:
|
|
33
|
+
async with SessionLocal() as session:
|
|
34
|
+
try:
|
|
35
|
+
yield session
|
|
36
|
+
except Exception:
|
|
37
|
+
await session.rollback()
|
|
38
|
+
raise
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Index, Integer, String, func
|
|
7
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
8
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Base(DeclarativeBase):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Paste(Base):
|
|
16
|
+
__tablename__ = "pastes"
|
|
17
|
+
|
|
18
|
+
id: Mapped[uuid.UUID] = mapped_column(
|
|
19
|
+
UUID(as_uuid=True),
|
|
20
|
+
primary_key=True,
|
|
21
|
+
default=uuid.uuid4,
|
|
22
|
+
server_default=func.gen_random_uuid(),
|
|
23
|
+
)
|
|
24
|
+
slug: Mapped[str] = mapped_column(String(12), unique=True, nullable=False)
|
|
25
|
+
blob_key: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
26
|
+
title: Mapped[str | None] = mapped_column(nullable=True)
|
|
27
|
+
owner_key_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
28
|
+
password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
29
|
+
sandbox_mode: Mapped[str] = mapped_column(
|
|
30
|
+
String(20), nullable=False, default="strict", server_default="strict"
|
|
31
|
+
)
|
|
32
|
+
expires_at: Mapped[datetime | None] = mapped_column(
|
|
33
|
+
DateTime(timezone=True), nullable=True
|
|
34
|
+
)
|
|
35
|
+
view_count: Mapped[int] = mapped_column(
|
|
36
|
+
BigInteger, nullable=False, default=0, server_default="0"
|
|
37
|
+
)
|
|
38
|
+
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
39
|
+
parent_slug: Mapped[str | None] = mapped_column(String(12), nullable=True)
|
|
40
|
+
deleted_at: Mapped[datetime | None] = mapped_column(
|
|
41
|
+
DateTime(timezone=True), nullable=True
|
|
42
|
+
)
|
|
43
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
44
|
+
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
45
|
+
)
|
|
46
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
47
|
+
DateTime(timezone=True),
|
|
48
|
+
nullable=False,
|
|
49
|
+
server_default=func.now(),
|
|
50
|
+
onupdate=func.now(),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
__table_args__ = (
|
|
54
|
+
CheckConstraint("sandbox_mode IN ('strict', 'relaxed')", name="ck_sandbox_mode"),
|
|
55
|
+
CheckConstraint("size_bytes <= 10485760", name="ck_size_bytes"),
|
|
56
|
+
Index("idx_pastes_slug", "slug"),
|
|
57
|
+
Index("idx_pastes_expires_at", "expires_at"),
|
|
58
|
+
Index("idx_pastes_parent_slug", "parent_slug"),
|
|
59
|
+
Index("idx_pastes_created_at", "created_at"),
|
|
60
|
+
)
|