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
+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
```