124 lines
6.9 KiB
Markdown
124 lines
6.9 KiB
Markdown
# 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
|
||
```
|