# 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=` | 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=`. 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 ```