Minimal, self-hosted Markdown sharing service. Upload Markdown via API, get a clean URL with GitHub-style rendering — Mermaid diagrams, syntax highlighting, KaTeX math, and optional password protection.
You're deep in a conversation with an AI — it just generated a detailed analysis, a code review, a diagram, or a structured report. You need to share that output with someone who doesn't have access to the AI. Copy-paste into a chat? Email a screenshot?
mdshare is designed as a "share button" for AI output. One curl command, one URL, done. The recipient opens a clean, GitHub-style rendered page — with syntax highlighting, diagrams, math rendering — without needing any account, tool, or access to the AI.
- PUT API upload — Single endpoint with Bearer token auth, accepts
multipart/form-data - Password-protected shares — Optional
protectedflag generates a random view password - Image uploads — Inline images uploaded alongside Markdown, rewritten to correct paths
- Client-side rendering — marked.js, highlight.js, Mermaid, KaTeX, DOMPurify — all via CDN
- SQLite storage — Single
sharestable, no external database needed - Single container — Flask + Gunicorn, no nginx, no microservices
mdshare is intentionally focused. It solves one problem — sharing rendered Markdown — and does not try to be everything.
- NOT a CMS — No pages, no navigation tree, no site structure. Each share is a standalone document.
- NOT a blog platform — No archives, no RSS, no metadata beyond creation time and expiry.
- NOT a collaboration tool — No editing, no comments, no versioning, no multi-user access.
- NOT a general file host — Markdown only. Images are only accepted as inline Markdown attachments.
- NOT a pastebin with public discovery — No index, no search, no public listing. Shares are URL-access only.
- NOT permanent storage — Shares expire by default (TTL = 7 days). mdshare is for sharing, not archiving.
The fastest way to run mdshare is with Docker Compose:
# Clone and start
git clone https://github.com/gelse/mdshare.git
cd mdshare
echo "MDSHARE_MASTER_PASSWORD=your-secret-password" > .env
docker compose up -d
# Upload your first Markdown share
curl -X PUT http://localhost:8080/api/share \
-H "Authorization: Bearer your-secret-password" \
-F "content=$(cat <<'EOF'
# Hello from AI
Rendered with **GitHub-style** Markdown, diagrams, and syntax highlighting.
```python
def greet():
print("Hello, world!")
```
EOF
)" \
-F "protected=no"
# Response: {"url": "http://localhost:8080/v/<12-char-id>"}
# Open the URL in a browser — instantly rendered.# Upload with password protection
curl -X PUT http://localhost:8080/api/share \
-H "Authorization: Bearer your-secret-password" \
-F "content=# Confidential Report" \
-F "protected=yes"
# Response: {"url": "http://localhost:8080/v/xyz789abc012", "password": "aB3dEfGh"}
# Open the URL, enter the password when prompted.That's it. One environment variable, one container, one upload command. The default TTL is 7 days — set ttl=0 for no expiry.
| Variable | Required | Default | Description |
|---|---|---|---|
MDSHARE_MASTER_PASSWORD |
Yes | changeme |
Master password for upload API auth |
MDSHARE_DATA_DIR |
No | /app/data |
SQLite DB + image storage directory |
MDSHARE_MAX_SIZE |
No | 16777216 |
Max upload size in bytes (16 MB) |
MDSHARE_BASE_URL |
No | (auto-detected) | Explicit base URL for share links (e.g. https://mdshare.example.com). Overrides auto-detection from X-Forwarded-* headers. |
MDSHARE_DISPLAY_CONFIG |
No | ${MDSHARE_DATA_DIR}/display.yaml |
Path to display defaults YAML file |
Upload Markdown content with optional images and password protection.
Headers:
Authorization: Bearer <master-password>(required)
Form fields:
| Field | Type | Required | Description |
|---|---|---|---|
content |
text | Yes | Markdown content |
protected |
text | No | "yes" or "no" (default: "no") |
ttl |
int | No | TTL in hours; 0 means no expiry (default: 168 = 7 days) |
display_config |
JSON | No | Per-share display overrides (theme, fonts, etc.). See Display Configuration for the full key reference. |
| any file | file | No | Images referenced in Markdown |
Response (201):
{
"url": "https://mdshare.example.com/v/<12-char-id>",
"valid_until": "2026-07-08T05:55:10.346000+00:00"
}Response with password (201, when protected=yes):
{
"url": "https://mdshare.example.com/v/<12-char-id>",
"password": "<8-char-random>",
"valid_until": "2026-07-08T05:55:10.346000+00:00"
}Response with ttl=0 (201, no expiry):
{
"url": "https://mdshare.example.com/v/<12-char-id>"
}Errors:
| Status | Meaning |
|---|---|
| 400 | Missing/empty content or invalid ttl value |
| 401 | Missing/wrong master password |
| 404 | Share expired or not found |
| 413 | Content exceeds size limit |
Serves the viewer HTML page. Always accessible — the viewer handles password prompting for protected content.
Returns raw Markdown content. For protected shares, requires ?pw=<password> query parameter.
Errors:
| Status | Meaning |
|---|---|
| 401 | Protected share, no password provided |
| 401 | Wrong password |
| 404 | Share not found |
Serves uploaded images for a share. No authentication required.
Health check endpoint. Returns {"status": "ok"}.
List all active (non-expired) shares with pagination. Requires master password authentication.
Query Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
int | 1 |
1-based page number (must be ≥ 1) |
page_size |
int | 50 |
Number of shares per page (1–200) |
Headers:
Authorization: Bearer <MDSHARE_MASTER_PASSWORD>
Response (200 OK):
{
"shares": [
{
"id": "abc123def456",
"url": "https://example.com/v/abc123def456",
"created_at": "2026-06-30T12:00:00",
"valid_until": "2026-07-01T12:00:00",
"protected": false
}
],
"page": 1,
"page_size": 50,
"total_pages": 1,
"total_count": 1
}Errors:
| Status | Meaning |
|---|---|
| 400 | Invalid query parameter (page < 1, page_size out of range, non-integer value) |
| 401 | Missing or invalid Authorization header |
Auto-generated Swagger UI documentation for the entire REST API. Browse all endpoints, inspect request/response schemas, and use the Authorize button to set your Bearer token for interactive try-out.
The OpenAPI specification is rendered via Flasgger (Swagger 2.0) and includes:
- Try it out for all endpoints — click Authorize and paste
Bearer <MDSHARE_MASTER_PASSWORD>to enable authenticated requests - Request schemas — JSON bodies, query parameters, path parameters, and multipart form fields documented
- Response schemas — Status codes and response body structures for every endpoint
No authentication required to view the documentation.
mdshare is ready to run behind a reverse proxy. It respects X-Forwarded-Proto, X-Forwarded-Host, and X-Forwarded-Port headers (via Werkzeug's ProxyFix middleware).
server {
listen 443 ssl;
server_name mdshare.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}labels:
- "traefik.http.routers.mdshare.rule=Host(`mdshare.example.com`)"
- "traefik.http.services.mdshare.loadbalancer.server.port=5000"If auto-detection doesn't produce the correct URL, set MDSHARE_BASE_URL=https://mdshare.example.com explicitly.
mdshare exposes a Model Context Protocol (MCP) endpoint at /api/mcp, allowing AI agents (Claude, Roo Code, Cursor, etc.) to create, retrieve, and inspect Markdown shares directly.
Endpoint:
https://mdshare.example.com/api/mcp
The endpoint uses Streamable HTTP transport (stateless, JSON responses). No separate process or port — the MCP handler shares the same process as the REST API.
| Tool | Description |
|---|---|
create_share |
Upload Markdown content as a new share. Supports password protection, base64-encoded images, optional ttl_hours (default: 168 = 7 days; 0 = no expiry), and optional display_config dict for per-share display overrides (see Display Configuration). |
get_share |
Retrieve raw Markdown content. Provide password for protected shares. |
get_share_info |
Check if a share exists and whether it's password-protected, without returning content. |
health_check |
Verify the service is operational (storage backend reachable). |
list_shares |
List all active (non-expired) shares with pagination. Supports page_size (1–200, default 50) and page (default 1) parameters. Returns pagination metadata. |
Add mdshare to your MCP client's configuration. Replace mdshare.example.com with your actual deployment host.
Roo Code / VS Code:
{
"mcpServers": {
"mdshare": {
"url": "https://mdshare.example.com/api/mcp",
"transport": "streamable-http"
}
}
}Claude Desktop:
{
"mcpServers": {
"mdshare": {
"type": "streamableHttp",
"url": "https://mdshare.example.com/api/mcp"
}
}
}Generic (any MCP client with Streamable HTTP support):
{
"mcpServers": {
"mdshare": {
"url": "https://mdshare.example.com/api/mcp"
}
}
}After configuration, the AI agent automatically discovers all five tools via the MCP handshake. No additional setup or API keys required.
- Python 3.13+
- Docker (for containerized deployment)
# Create virtual environment
python -m venv venv
source venv/bin/activate
# Install runtime deps
pip install -r backend/requirements.txt
# Install dev deps
pip install -r requirements-dev.txt
# Run tests
make test
# Run with coverage
make test-coverage# Without Docker
MDSHARE_MASTER_PASSWORD=dev-password \
MDSHARE_DATA_DIR=./data \
python -m flask --app backend.app run --port 5000# Build image
docker build -t mdshare .
# Test image (includes test dependencies)
docker build -t mdshare:test -f- . <<'DOCKERFILE'
FROM python:3.13-slim
RUN pip install pytest pytest-cov httpx
COPY backend/ /app/backend/
COPY requirements-dev.txt /app/
RUN pip install -r /app/requirements-dev.txt
WORKDIR /app
CMD ["python", "-m", "pytest", "backend", "--junitxml=/app/junit.xml"]
DOCKERFILEIntegration tests in tests/integration/ exercise all HTTP endpoints (health, upload, view, raw, images, themes) against a separately deployed mdshare instance — no Docker containers are managed by the test suite itself.
Prerequisites:
- A running mdshare instance accessible over HTTP
httpxandpytestinstalled (pip install -r requirements-dev.txt)
Running against a specific URL:
# Set the target deployment
export DEPLOYMENT_HOST=http://192.168.1.100:5000
# Optional: set the master password (default: test-integration-master-pw)
export DEPLOYMENT_MASTER_PASSWORD=your-master-password
# Run via make
make test-integration
# Or run directly
python -m pytest tests/integration/ -v -m integrationThe integration_base_url fixture reads DEPLOYMENT_HOST from the environment. If unset, all integration tests are skipped. Before yielding, the fixture polls GET /api/health (up to 30 seconds) to confirm the deployment is reachable.
The project uses a unified ci.yml workflow with two jobs:
| Job | Trigger | Purpose |
|---|---|---|
test |
Every push / PR (all branches) | Runs make ci-unit-test (Docker Compose test profile), uploads results, publishes JUnit report |
publish |
Push to release branch only, after test passes |
Logs into GHCR, runs make docker-build, retags with ghcr.io/gelse/mdshare prefix, pushes latest + SHA tags |
The publish job uses needs: test and a branch filter — only passing code on the release branch is published.
New target:
# Build production image with generic tags + OCI labels
make docker-buildThis produces gelse/mdshare:latest and gelse/mdshare:<git-sha> (registry-agnostic). The CI workflow adds the ghcr.io/ prefix before pushing to the GitHub Container Registry.
- Login: Automatic via
docker/login-action@v3using the built-inGITHUB_TOKEN(no secrets to configure) - Tags pushed:
ghcr.io/gelse/mdshare:latestandghcr.io/gelse/mdshare:<short-sha> - Permission: The
publishjob requestspackages: writescoped to the job only
The app auto-migrates the SQLite schema on startup via a try/except ALTER TABLE — no manual steps required for new or existing deployments. For reference, the migration adds a single column:
ALTER TABLE shares ADD COLUMN valid_until TEXT;This column stores an ISO 8601 UTC timestamp. NULL means the share never expires (grandfathered shares or ttl=0 uploads).
2.0.0 — Retention time / TTL feature
Licensed under the Apache License, Version 2.0.
Copyright 2026 Werner Schiller <github@gelse.net>. See NOTICE for attribution details.
mdshare supports flexible display customization through global defaults (optional YAML file) and per-share overrides (upload-time JSON). The viewer applies these settings as CSS custom properties on the rendered page.
| Option | Key | Type | Default | Description |
|---|---|---|---|---|
| Font Family | font_family |
string | "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" |
CSS font-family for body text |
| Font Size | font_size |
string | "16px" |
Base text size (any valid CSS size) |
| Line Height | line_height |
string | "1.6" |
Text line height |
| Max Width | max_width |
string | "900px" |
Maximum content width (any valid CSS width) |
| Theme | theme |
string | "auto" |
"light", "dark", or "auto" (follows system preference) |
| Code Font Size | code_font_size |
string | "14px" |
Font size for code blocks |
| Code Line Numbers | code_line_numbers |
boolean | false |
Show line numbers in code blocks |
| Custom CSS | custom_css |
string | "" |
Raw CSS string injected into viewer page |
Create a display.yaml file to set system-wide display defaults for all shares. The path resolution follows this priority:
MDSHARE_DISPLAY_CONFIGenvironment variable (if set, uses exactly that path)${MDSHARE_DATA_DIR}/display.yaml(default, e.g./data/display.yaml)
The file is optional — if neither location exists, the hardcoded defaults apply. Invalid or malformed YAML is silently ignored (falling back to defaults).
The repository ships a reference template at backend/display_defaults.yaml with all eight options listed and commented out. Copy this file as a starting point:
# /data/display.yaml
font_family: "Georgia, serif"
theme: "dark"
code_line_numbers: trueBind-mount your display.yaml into the container's data directory:
services:
mdshare:
image: mdshare:latest
volumes:
- ./display.yaml:/data/display.yaml:roOr set MDSHARE_DISPLAY_CONFIG to an arbitrary path:
services:
mdshare:
image: mdshare:latest
environment:
- MDSHARE_DISPLAY_CONFIG=/config/display.yaml
volumes:
- ./display.yaml:/config/display.yaml:roOverride any display option at upload time by passing display_config as a JSON-formatted form field. These merge on top of the global defaults:
curl -X PUT http://localhost:8080/api/share \
-H "Authorization: Bearer your-secret-password" \
-F "content=# Custom Styled Document" \
-F 'display_config={"theme":"dark","code_line_numbers":true}'Only the keys you provide are overridden; all other options fall through to the global defaults (or hardcoded defaults).
| Key | Type | Default | Description |
|---|---|---|---|
theme |
string | "auto" |
Color theme. Valid values: "light", "dark", "auto" (follows system preference). |
font_family |
string | "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif" |
CSS font-family for body text. |
font_size |
string | "16px" |
CSS font-size for body text. |
line_height |
string | "1.6" |
CSS line-height for body text. |
max_width |
string | "900px" |
CSS max-width for the content container. |
code_font_size |
string | "14px" |
CSS font-size for code blocks. |
code_line_numbers |
boolean | false |
Show line numbers in code blocks. |
custom_css |
string | "" |
Raw CSS injected into the viewer page for custom branding. |
curl -X PUT ... \
-F 'display_config={"font_family":"Georgia, serif","font_size":"20px","max_width":"700px"}'Georgia font, larger text, narrower reading width — ideal for long-form content.
curl -X PUT ... \
-F 'display_config={"theme":"dark","code_line_numbers":true,"code_font_size":"16px"}'Dark theme with line numbers and larger code font — great for sharing code snippets.
curl -X PUT ... \
-F 'display_config={"custom_css":".header{background:#333;color:#fff;padding:1em;text-align:center}"}'Inject a company header/footer via custom CSS — perfect for client-facing shares.
curl -X PUT ... \
-F 'display_config={"theme":"light"}'Force light theme regardless of system preference.
# Markdown file referencing local images
cat > post.md << 'EOF'
# Screenshot

EOF
# Upload with image
curl -X PUT http://localhost:8080/api/share \
-H "Authorization: Bearer your-secret-password" \
-F "content=@post.md" \
-F "protected=no" \
-F "screenshot.png=@screenshot.png"Image references in Markdown (like ) are automatically rewritten to the correct /v/<id>/img/filename.png path.