Files
2026-09-04 18:30:15 +01:00

124 lines
6.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```