Initial commit

This commit is contained in:
Morpheus Sandmann
2026-09-04 18:30:15 +01:00
commit 1deb74b35b
7 changed files with 625 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
# ============================================================================
# mcp-linkgen — Environment Configuration
# ============================================================================
# Server
HOST=0.0.0.0
PORT=8000
# Workspace — directory containing files to share via download links
# Leave the below line commented out to automatically mount the Turnstone workspace volume
# WORKSPACE_ROOT=/workspace
# Public base URL used when generating download links.
# Set to your public hostname (with trailing slash path, no trailing slash).
# Examples:
# http://localhost:8000 (local dev)
# https://linkgen.example.com (production behind TLS)
PUBLIC_BASE_URL=http://localhost:8000
# Download link TTL (time-to-live) settings in minutes
DOWNLOAD_DEFAULT_TTL=10
DOWNLOAD_MIN_TTL=1
DOWNLOAD_MAX_TTL=60
# CORS — Comma-separated list of allowed origins.
# Use "*" to allow all origins (development default).
# For production, restrict to your domain(s).
# Examples:
# CORS_ORIGINS=*
# CORS_ORIGINS=https://myapp.com,https://admin.myapp.com
CORS_ORIGINS=*
# Rate limiting — per-IP sliding window (0 = disabled).
MCP_RATE_LIMIT=60 # max MCP tool requests per minute per IP
DOWNLOAD_RATE_LIMIT=10 # max download requests per minute per IP
RATE_LIMIT_WINDOW=60 # sliding window size in seconds
MCP_ENDPOINT=/mcp # MCP HTTP endpoint path (FastMCP default)
+37
View File
@@ -0,0 +1,37 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
build/
dist/
*.egg-info/
*.egg
# Virtual environments
venv/
.venv/
env/
ENV/
# Environment files — may contain secrets
.env
.env.local
.env.production
# IDEs
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# OS files
.DS_Store
Thumbs.db
# Workspace (runtime data, not source)
workspace/
# Docker
docker-compose.override.yaml
+21
View File
@@ -0,0 +1,21 @@
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1
RUN pip install --no-cache-dir fastmcp python-dotenv
RUN groupadd -g 1000 app && \
useradd -m -u 1000 -g 1000 -s /bin/bash app
WORKDIR /workspace
COPY server.py /app/server.py
USER 1000:1000
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=10s --retries=3 --start-period=10s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["python", "/app/server.py"]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Aaron Tomsett
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+123
View File
@@ -0,0 +1,123 @@
# mcp-linkgen
A lightweight [Model Context Protocol](https://modelcontextprotocol.io/) server that generates ephemeral, single-use download links for files stored in a configurable workspace directory. Intended for use in the [Turnstone project](https://github.com/turnstonelabs/turnstone).
## Features
- **Single MCP tool** — `generate_download_link` mints a URL containing a cryptographically random token.
- **Single-use tokens** — each link is consumed on first request; no replay possible.
- **Time-limited** — links expire after a configurable TTL (default 10 min, clamped to 1–60 min).
- **Path traversal protection** — `os.path.realpath()` resolves symlinks and `..` before validating against the workspace root.
- **Directory blocking** — only files can be shared; directories are rejected.
- **In-memory token store** — tokens are never written to disk.
- **Configurable CORS** — restrict cross-origin access to specific domains.
- **Per-IP rate limiting** — in-memory sliding-window limits on the MCP and download endpoints.
- **Structured JSON logging** — every tool call and download is logged as a single JSON line.
- **Docker + native** — runs in a ~120 MB container or directly via `python server.py`.
## Quick Start
### Docker
Copy .env example and change any defaults as required:
```bash
cp .env.example .env
```
Build and run the server:
```bash
docker compose up --build
```
The server listens on **port 8000** by default.
## Configuration
All settings are read from environment variables. A `.env.example` file is provided with sensible defaults — edit it before starting the server.
| Variable | Default | Description |
|---|---|---|
| `HOST` | `0.0.0.0` | Bind address |
| `PORT` | `8000` | Listen port |
| `WORKSPACE_ROOT` | `/workspace` | Absolute path to the directory containing files to share. Leave undefined to mount `turnstone_workspace` volume |
| `PUBLIC_BASE_URL` | `http://localhost:8000` | User-facing base URL for generated links |
| `DOWNLOAD_DEFAULT_TTL` | `10` | Default link lifetime in minutes |
| `DOWNLOAD_MIN_TTL` | `1` | Minimum allowed TTL (minutes) |
| `DOWNLOAD_MAX_TTL` | `60` | Maximum allowed TTL (minutes) |
| `CORS_ORIGINS` | `*` | Comma-separated list of allowed origins, or `*` for all |
| `MCP_RATE_LIMIT` | `60` | Max MCP tool requests per minute per IP (`0` disables) |
| `DOWNLOAD_RATE_LIMIT` | `10` | Max download requests per minute per IP (`0` disables) |
| `RATE_LIMIT_WINDOW` | `60` | Sliding window size in seconds |
| `MCP_ENDPOINT` | `/mcp` | HTTP path for the MCP endpoint (used by the rate limiter) |
### CORS examples
```env
# Open (development)
CORS_ORIGINS=*
# Restricted to your domain
CORS_ORIGINS=https://myapp.com,https://admin.myapp.com
```
## Endpoints
| Method | Path | Description |
|---|---|---|
| `POST` | `/mcp` | MCP protocol endpoint (Streamable HTTP) |
| `GET` | `/health` | Liveness probe — returns `{"status":"healthy"}` |
| `GET` | `/download?t=<token>` | Streams the file, then invalidates the token |
## Security Model
### Strengths
| Property | Mechanism |
|---|---|
| **Single-use tokens** | Token is deleted from the in-memory store on first successful download, preventing replay attacks. |
| **Time-limited links** | Tokens carry an `expires_at` timestamp. Expired tokens are rejected (HTTP 410) and periodically garbage-collected. |
| **Path traversal protection** | `_validate_download_path()` calls `os.path.realpath()` to resolve symlinks and `..`, then verifies the result starts with `WORKSPACE_ROOT + "/"`. |
| **Directory isolation** | `IsADirectoryError` is raised for directories — callers must archive them first. |
| **In-memory store** | Tokens are never written to disk or logged, limiting exposure surface. |
| **CORS restrictions** | Configurable `CORS_ORIGINS` prevents arbitrary cross-origin requests to the `/download` endpoint. |
| **Rate limiting** | Per-IP sliding-window limits on the MCP (`POST /mcp`) and download (`GET /download`) endpoints slow brute-force token guessing and API abuse. Exceeding a limit returns HTTP 429 with a `Retry-After` header. |
### Drawbacks and precautions
| Concern | Details |
|---|---|
| **No transport encryption** | The server itself speaks plain HTTP. Deploy behind a TLS-terminating reverse proxy (nginx, Caddy, cloud LB) and set `PUBLIC_BASE_URL` to the `https://` address. |
| **No client binding** | Anyone who obtains a token URL can use it — tokens are not tied to an IP, session, or API key. Treat generated URLs as secrets. |
| **In-memory persistence** | All tokens are lost on server restart or crash. This is a feature (no state leakage) but means links do not survive restarts. |
| **Rate limits are in-memory and per-instance** | Rate-limit counters live in process memory and are not shared across multiple server instances. For horizontally-scaled deployments, front with a shared rate limiter (e.g., a reverse proxy or Redis-backed middleware). |
| **Rate limits key on IP** | Counters are keyed by the client IP (honoring a single `X-Forwarded-For` entry). Behind a proxy, ensure the proxy sets `X-Forwarded-For` correctly; otherwise all clients may share one bucket. A determined attacker can rotate IPs to bypass limits — this is a deterrent, not a hard boundary. |
| **Workspace scope** | Any file inside `WORKSPACE_ROOT` is reachable by an attacker who knows the path. Limit `WORKSPACE_ROOT` to the narrowest directory needed. |
### Opaque query parameter
The download endpoint reads the token from a deliberately opaque single-character query parameter: `?t=<token>`. This naming avoids common credential-leak heuristics that flag parameters named `token`, `key`, `api_key`, etc., so links pass through guardrails without requiring an allow-list rule.
Two things to note:
- **No backward compatibility** — `?t=` is the only accepted parameter name. The old `?token=` is not read and will return HTTP 400. Links issued by prior versions of this server will no longer work; regenerate them.
- **This is not a security control** — renaming the parameter is a guardrail workaround (security through obscurity), not a hardening measure. Anyone who obtains a link can still download the file. The actual protections remain token entropy (`secrets.token_urlsafe(32)`), single-use enforcement, TTL expiry, and per-IP rate limiting.
## Usage with an LLM client
1. The LLM calls `generate_download_link(path="/workspace/report.pdf", ttl_minutes=15)`.
2. The server returns `http://localhost:8000/download?t=a1b2c3...`.
3. The LLM presents the link to the user (or the user downloads it directly).
4. On the first `GET /download?t=...`, the file is streamed and the token is destroyed.
## Project Structure
```
mcp-linkgen/
├── server.py # Single-module MCP server (all logic)
├── .env.example # Environment configuration
├── Dockerfile # Slim container build
├── compose.yaml # Docker Compose service definition
├── README.md # This file
├── LICENSE # MIT license
└── .gitignore
```
+22
View File
@@ -0,0 +1,22 @@
---
volumes:
workspace:
external: true
name: turnstone_workspace
services:
mcp-linkgen:
build: .
env_file: .env
ports:
- "${PORT:-8000}:${PORT:-8000}"
volumes:
- "${WORKSPACE_ROOT:-workspace}:/workspace"
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:{PORT:-8000}/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
+364
View File
@@ -0,0 +1,364 @@
#!/usr/bin/env python3
"""
mcp-linkgen — Lightweight MCP server for generating ephemeral download links.
Exposes a single MCP tool (`generate_download_link`) that mints a single-use,
time-limited HTTPS download URL for any file inside the configured workspace
directory. An HTTP route (`/download`) serves the file once and invalidates
the token.
Designed to run standalone via `python server.py` or inside a minimal Docker
container.
"""
import os
import sys
import json
import logging
import secrets
import time
from collections import defaultdict
from typing import Dict
from fastmcp import FastMCP
from fastmcp.server.middleware import Middleware, MiddlewareContext
from starlette.middleware import Middleware as StarletteMiddleware
from starlette.middleware.cors import CORSMiddleware
from starlette.responses import FileResponse, JSONResponse
# ---------------------------------------------------------------------------
# Optional .env loading (convenience for native/local development)
# ---------------------------------------------------------------------------
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
# ---------------------------------------------------------------------------
# Logging — structured JSON to stdout
# ---------------------------------------------------------------------------
class JSONLogFormatter(logging.Formatter):
"""Emit one JSON object per log line for machine-parseable output."""
def format(self, record):
log_record = {
"timestamp": self.formatTime(record, self.datefmt),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"file": f"{record.filename}:{record.lineno}",
}
if record.exc_info:
log_record["exception"] = self.formatException(record.exc_info)
return json.dumps(log_record)
_json_handler = logging.StreamHandler(sys.stdout)
_json_handler.setFormatter(JSONLogFormatter())
logging.basicConfig(
level=logging.INFO,
handlers=[_json_handler],
force=True,
)
logger = logging.getLogger("mcp-linkgen")
# ---------------------------------------------------------------------------
# Configuration (all values overridable via environment variables)
# ---------------------------------------------------------------------------
HOST = os.environ.get("HOST", "0.0.0.0")
PORT = int(os.environ.get("PORT", "8000"))
WORKSPACE_ROOT = os.path.abspath(os.environ.get("WORKSPACE_ROOT", "/workspace"))
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://localhost:8000")
DOWNLOAD_DEFAULT_TTL = int(os.environ.get("DOWNLOAD_DEFAULT_TTL", "10")) # minutes
DOWNLOAD_MIN_TTL = int(os.environ.get("DOWNLOAD_MIN_TTL", "1")) # minutes
DOWNLOAD_MAX_TTL = int(os.environ.get("DOWNLOAD_MAX_TTL", "60")) # minutes
# CORS origins — comma-separated list, or "*" for all.
_cors_raw = os.environ.get("CORS_ORIGINS", "*").strip()
CORS_ORIGINS: list[str] = (
["*"] if _cors_raw == "*" else [o.strip() for o in _cors_raw.split(",") if o.strip()]
)
# Rate limiting (per-IP sliding window; 0 = disabled).
MCP_RATE_LIMIT = int(os.environ.get("MCP_RATE_LIMIT", "30")) # req/min per IP
DOWNLOAD_RATE_LIMIT = int(os.environ.get("DOWNLOAD_RATE_LIMIT", "10")) # req/min per IP
RATE_LIMIT_WINDOW = int(os.environ.get("RATE_LIMIT_WINDOW", "60")) # sliding window (s)
MCP_ENDPOINT = os.environ.get("MCP_ENDPOINT", "/mcp") # MCP HTTP path (FastMCP default)
logger.info("Workspace root: %s", WORKSPACE_ROOT)
logger.info("Public base URL: %s", PUBLIC_BASE_URL)
logger.info("CORS origins: %s", CORS_ORIGINS)
logger.info("Rate limits — MCP: %d/min, download: %d/min, window: %ds",
MCP_RATE_LIMIT, DOWNLOAD_RATE_LIMIT, RATE_LIMIT_WINDOW)
# ---------------------------------------------------------------------------
# FastMCP application
# ---------------------------------------------------------------------------
mcp = FastMCP("mcp-linkgen")
# In-memory token store — tokens never touch disk.
TOKEN_STORE: Dict[str, dict] = {}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_download_path(raw_path: str) -> str:
"""Resolve *raw_path* and ensure it lives inside ``WORKSPACE_ROOT``.
Returns the canonical absolute path on success.
Raises
------
PermissionError
If the resolved path escapes the workspace root.
FileNotFoundError
If the file does not exist.
IsADirectoryError
If the target is a directory (archives must be used for directories).
"""
canonical_path = os.path.realpath(raw_path)
if not canonical_path.startswith(WORKSPACE_ROOT + "/"):
raise PermissionError("Access denied: path escapes WORKSPACE_ROOT")
if not os.path.exists(canonical_path):
raise FileNotFoundError(f"File not found: {raw_path}")
if os.path.isdir(canonical_path):
raise IsADirectoryError(
"Target path is a directory. Please compress it to an archive first."
)
return canonical_path
def _purge_expired_tokens():
"""Remove all tokens whose TTL has elapsed."""
now = time.time()
expired = [k for k, v in TOKEN_STORE.items() if now > v["expires_at"]]
for k in expired:
TOKEN_STORE.pop(k, None)
# ---------------------------------------------------------------------------
# HTTP routes
# ---------------------------------------------------------------------------
@mcp.custom_route("/health", methods=["GET"])
async def health_check(request):
"""Liveness probe — returns 200 when the server is running."""
return JSONResponse({"status": "healthy", "service": "mcp-linkgen"})
@mcp.custom_route("/download", methods=["GET"])
async def download_file(request):
"""Serve a file identified by a single-use token, then invalidate it."""
_purge_expired_tokens()
token = request.query_params.get("t")
if not token:
return JSONResponse({"detail": "Missing token parameter."}, status_code=400)
record = TOKEN_STORE.get(token)
if not record:
return JSONResponse(
{"detail": "Invalid or previously used download link."}, status_code=403
)
# Immediately consume the token (single-use).
del TOKEN_STORE[token]
if time.time() > record["expires_at"]:
return JSONResponse({"detail": "Download link has expired."}, status_code=410)
file_path = record["file_path"]
if not os.path.exists(file_path):
return JSONResponse(
{"detail": "File no longer exists on host."}, status_code=404
)
return FileResponse(
path=file_path,
filename=os.path.basename(file_path),
media_type="application/octet-stream",
)
# ---------------------------------------------------------------------------
# Middleware — rate limiting, structured logging, CORS
# ---------------------------------------------------------------------------
class RateLimitMiddleware:
"""Per-IP sliding-window rate limiter for selected routes.
Wraps a Starlette application. Routes matching ``MCP_ENDPOINT`` (POST)
or ``/download`` (GET) are subject to their respective limits. All other
requests pass through unconditionally.
"""
def __init__(self, app, mcp_limit: int, download_limit: int, window: int, mcp_path: str):
self.app = app
self.mcp_limit = mcp_limit
self.download_limit = download_limit
self.window = window
self.mcp_path = mcp_path
self._hits: Dict[str, list[float]] = defaultdict(list)
self._last_cleanup = time.time()
# -- internal helpers ---------------------------------------------------
def _client_ip(self, request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _prune(self, timestamps: list[float], now: float) -> list[float]:
cutoff = now - self.window
return [t for t in timestamps if t > cutoff]
def _cleanup_if_stale(self, now: float):
"""Periodically purge stale entries to bound memory usage."""
if now - self._last_cleanup > self.window:
stale_keys = [
ip for ip, ts in self._hits.items()
if not ts or ts[-1] <= now - self.window
]
for ip in stale_keys:
del self._hits[ip]
self._last_cleanup = now
# -- ASGI interface -----------------------------------------------------
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
now = time.time()
self._cleanup_if_stale(now)
path = scope.get("path", "")
method = scope.get("method", "")
limit = None
if path == self.mcp_path and method == "POST" and self.mcp_limit > 0:
limit = self.mcp_limit
elif path == "/download" and method == "GET" and self.download_limit > 0:
limit = self.download_limit
if limit is not None:
ip = self._client_ip_from_scope(scope)
self._hits[ip] = self._prune(self._hits[ip], now)
if len(self._hits[ip]) >= limit:
retry_after = int(self.window - (now - self._hits[ip][0])) + 1
response = JSONResponse(
{"detail": "Rate limit exceeded. Try again later."},
status_code=429,
headers={"Retry-After": str(max(retry_after, 1))},
)
return await response(scope, receive, send)
self._hits[ip].append(now)
return await self.app(scope, receive, send)
def _client_ip_from_scope(self, scope) -> str:
headers = dict(scope.get("headers", []))
forwarded = headers.get(b"x-forwarded-for")
if forwarded:
return forwarded.decode().split(",")[0].strip()
client = scope.get("client")
return client[0] if client else "unknown"
class ToolObserverMiddleware(Middleware):
"""Log every MCP tool invocation and its result."""
async def on_call_tool(self, context: MiddlewareContext, call_next):
tool_name = getattr(context.message, "name", "unknown")
arguments = getattr(context.message, "arguments", {})
logger.info("LLM REQUEST | Tool: '%s' | Args: %s", tool_name, arguments)
try:
result = await call_next(context)
content = getattr(result, "content", result)
preview = str(content)[:250].replace("\n", " ")
logger.info("SERVER RESPONSE | Tool: '%s' completed | Preview: %s", tool_name, preview)
return result
except Exception as exc:
logger.error("SERVER ERROR | Tool '%s' crashed: %s", tool_name, exc)
raise
mcp.add_middleware(ToolObserverMiddleware())
middleware_config = [
StarletteMiddleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=[
"mcp-protocol-version",
"mcp-session-id",
"Authorization",
"Content-Type",
],
expose_headers=["mcp-session-id"],
),
StarletteMiddleware(
RateLimitMiddleware,
mcp_limit=MCP_RATE_LIMIT,
download_limit=DOWNLOAD_RATE_LIMIT,
window=RATE_LIMIT_WINDOW,
mcp_path=MCP_ENDPOINT,
),
]
# ---------------------------------------------------------------------------
# MCP tool — generate_download_link
# ---------------------------------------------------------------------------
@mcp.tool()
def generate_download_link(path: str, ttl_minutes: int = DOWNLOAD_DEFAULT_TTL) -> str:
"""Generate a secure, single-use, temporary download link for a file.
The link points at the ``/download`` HTTP endpoint on this server.
Each link is valid for exactly one request; after that the underlying token
is destroyed. Tokens also expire after *ttl_minutes* even if unused.
Parameters
----------
path : str
Absolute path to the file. Must reside inside the configured
workspace directory (default ``/workspace``).
ttl_minutes : int, optional
How many minutes the link stays valid (default: 10).
Clamped to the range ``DOWNLOAD_MIN_TTL`` – ``DOWNLOAD_MAX_TTL``.
Returns
-------
str
A full URL string on success, or an error message prefixed with
``Error:``.
"""
try:
abs_path = _validate_download_path(path)
except (PermissionError, FileNotFoundError, IsADirectoryError) as exc:
return f"Error: {exc}"
_purge_expired_tokens()
effective_ttl = max(DOWNLOAD_MIN_TTL, min(ttl_minutes, DOWNLOAD_MAX_TTL))
token = secrets.token_urlsafe(32)
expires_at = time.time() + (effective_ttl * 60)
TOKEN_STORE[token] = {
"file_path": abs_path,
"expires_at": expires_at,
}
url = f"{PUBLIC_BASE_URL}/download?t={token}"
logger.info("Generated download link for %s (ttl=%dm)", abs_path, effective_ttl)
return url
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
mcp.run(transport="http", host=HOST, port=PORT, middleware=middleware_config)