6.9 KiB
mcp-linkgen
A lightweight Model Context Protocol server that generates ephemeral, single-use download links for files stored in a configurable workspace directory. Intended for use in the Turnstone project.
Features
- Single MCP tool —
generate_download_linkmints 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:
cp .env.example .env
Build and run the server:
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
# 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
- The LLM calls
generate_download_link(path="/workspace/report.pdf", ttl_minutes=15). - The server returns
http://localhost:8000/download?t=a1b2c3.... - The LLM presents the link to the user (or the user downloads it directly).
- 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